mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 08:32:16 +01:00
Compare commits
279 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8875f4dfc4 | ||
|
|
61c8217ed5 | ||
|
|
f75912033e | ||
|
|
1d8630810b | ||
|
|
ce155fb43e | ||
|
|
67ebe17fcb | ||
|
|
f13a6c0a3b | ||
|
|
e1870429b9 | ||
|
|
5aa1cd7f40 | ||
|
|
de1b887d29 | ||
|
|
e662f5ce0b | ||
|
|
790b69a32e | ||
|
|
9aaab97d6a | ||
|
|
66317197b6 | ||
|
|
91757335ac | ||
|
|
be3145aae9 | ||
|
|
79425af848 | ||
|
|
45b47a1071 | ||
|
|
4b30567c39 | ||
|
|
1ed33a7400 | ||
|
|
ca1d11006c | ||
|
|
71f1f3dd22 | ||
|
|
64add9a542 | ||
|
|
a7c02438e4 | ||
|
|
910af7a44c | ||
|
|
05f9ac3745 | ||
|
|
5c43eefe46 | ||
|
|
b63f4c53d2 | ||
|
|
3d652bc829 | ||
|
|
c78ef8771d | ||
|
|
9a38f16219 | ||
|
|
e5363205a7 | ||
|
|
088b3274f2 | ||
|
|
fabe323e34 | ||
|
|
a731bf034e | ||
|
|
3cbf772acf | ||
|
|
9701f88ae3 | ||
|
|
7ca1d118a1 | ||
|
|
a7dfa21cbf | ||
|
|
ab81444773 | ||
|
|
02c4343dfd | ||
|
|
1bfe86738f | ||
|
|
7fe0e05050 | ||
|
|
10588efe25 | ||
|
|
bf4607d368 | ||
|
|
45f04ae8d4 | ||
|
|
674f1de183 | ||
|
|
368c4fa1a7 | ||
|
|
c737579b7f | ||
|
|
0b7c51a323 | ||
|
|
e7c77b7bf3 | ||
|
|
6c727b7e51 | ||
|
|
a9754d6a1c | ||
|
|
218052d5dd | ||
|
|
f112cb748f | ||
|
|
67a65c66b6 | ||
|
|
cd6add14a0 | ||
|
|
33f9f1ddf4 | ||
|
|
2131de77b0 | ||
|
|
0febb60a3f | ||
|
|
820b914291 | ||
|
|
536d3ee70d | ||
|
|
2544d82486 | ||
|
|
ea6854fadc | ||
|
|
147e3b1173 | ||
|
|
8252142e65 | ||
|
|
6e2270632e | ||
|
|
7d14546d3b | ||
|
|
b31024c362 | ||
|
|
85eaef05ad | ||
|
|
16e4c10862 | ||
|
|
904e0f61a8 | ||
|
|
70953aa66e | ||
|
|
d41de5d270 | ||
|
|
d8603f4f34 | ||
|
|
b13a8094ea | ||
|
|
28e07bad54 | ||
|
|
81cc483166 | ||
|
|
8d7cc55c28 | ||
|
|
083e0e307a | ||
|
|
80f3f89a89 | ||
|
|
3dc8c18849 | ||
|
|
0a6a97cf07 | ||
|
|
7676d7a239 | ||
|
|
679715f896 | ||
|
|
d5aa880336 | ||
|
|
08daa86766 | ||
|
|
f0c1952dfe | ||
|
|
198d9115cc | ||
|
|
6b0c01bd3e | ||
|
|
a1848d3c75 | ||
|
|
a1596f7c62 | ||
|
|
740e701ad3 | ||
|
|
71d571a25c | ||
|
|
2293300182 | ||
|
|
85c7f2806f | ||
|
|
27ecb4fe49 | ||
|
|
7afeefe84f | ||
|
|
651922194f | ||
|
|
1d1b4fb04d | ||
|
|
b418f1eb05 | ||
|
|
a9b44ee39e | ||
|
|
cb48355898 | ||
|
|
6ce47e3a20 | ||
|
|
09e511bc49 | ||
|
|
66ade27383 | ||
|
|
d96058d589 | ||
|
|
8631a15933 | ||
|
|
c0bf81ad7c | ||
|
|
8fd1fa203e | ||
|
|
51b907bcf5 | ||
|
|
ecb5d974c5 | ||
|
|
19c91aff19 | ||
|
|
8e0cc7dd2f | ||
|
|
6478528ebc | ||
|
|
3e3b24f611 | ||
|
|
6dc7d70a1f | ||
|
|
a6f4c5651c | ||
|
|
291a4d39fe | ||
|
|
e639a1dd73 | ||
|
|
e3a961623b | ||
|
|
1ca27fdba2 | ||
|
|
f72428056a | ||
|
|
bdb3f11ac7 | ||
|
|
2f69af1a52 | ||
|
|
984cf77ee0 | ||
|
|
1762596fce | ||
|
|
910eb9b770 | ||
|
|
16807bd57f | ||
|
|
8bc29ad92d | ||
|
|
51a913a5e4 | ||
|
|
c30da805b3 | ||
|
|
42ee593708 | ||
|
|
1b49b35365 | ||
|
|
333ab7066c | ||
|
|
66dbdc09b5 | ||
|
|
79f396f6ff | ||
|
|
62ba4877ac | ||
|
|
e65a5e6d22 | ||
|
|
711b43070d | ||
|
|
b2f957d1d8 | ||
|
|
249745f256 | ||
|
|
8ff9d3bcf1 | ||
|
|
99c328c302 | ||
|
|
8a9f0a245e | ||
|
|
9daecc127a | ||
|
|
119bba4f04 | ||
|
|
bf205c579d | ||
|
|
aa57b17254 | ||
|
|
7df786c5c6 | ||
|
|
b315464e98 | ||
|
|
1252ea076b | ||
|
|
d970e446a8 | ||
|
|
fe40f3727b | ||
|
|
10a290b065 | ||
|
|
0abb11b42a | ||
|
|
1eb6eb729a | ||
|
|
e371a4a3c3 | ||
|
|
f2b6bd3d4b | ||
|
|
d21f87b543 | ||
|
|
b8cdee4f45 | ||
|
|
b06d7445d9 | ||
|
|
2aa9cceb32 | ||
|
|
ae2a2a8a07 | ||
|
|
286555411b | ||
|
|
3c42aec180 | ||
|
|
a203ae00c6 | ||
|
|
68784616a4 | ||
|
|
5cd8c6a5da | ||
|
|
a3647fea8a | ||
|
|
300e73f07d | ||
|
|
03ced23f05 | ||
|
|
da8eeb5d8f | ||
|
|
16cf02896f | ||
|
|
c9f10d248e | ||
|
|
3297e41289 | ||
|
|
4e1b347797 | ||
|
|
4602332dbb | ||
|
|
e1f69ee533 | ||
|
|
d90e431b5b | ||
|
|
45a9eca0ce | ||
|
|
af202f4871 | ||
|
|
7b32fa35f3 | ||
|
|
0c40b230dc | ||
|
|
2baba67bfc | ||
|
|
b1e69a9059 | ||
|
|
f87362ec1b | ||
|
|
84dc57373a | ||
|
|
b3f4a02658 | ||
|
|
50304dbefe | ||
|
|
bf01af0bf3 | ||
|
|
0e3fe3463b | ||
|
|
3956763825 | ||
|
|
d6fe895f6e | ||
|
|
697fa08d8b | ||
|
|
669a35afef | ||
|
|
b1d94a3845 | ||
|
|
06869cf491 | ||
|
|
ed67d8514c | ||
|
|
07ec2f3d7b | ||
|
|
73d02e85c4 | ||
|
|
c1cf1eb37a | ||
|
|
7aecc94049 | ||
|
|
b55fbfeb8e | ||
|
|
20b8991fa4 | ||
|
|
e50de4201d | ||
|
|
ba2b876ee4 | ||
|
|
9b1399c9d5 | ||
|
|
020a2f00c2 | ||
|
|
8713946f35 | ||
|
|
bed8c25e40 | ||
|
|
7c51a09068 | ||
|
|
0f0a6ce042 | ||
|
|
a890968ee4 | ||
|
|
2267c5be00 | ||
|
|
e853cfb98a | ||
|
|
f89a0db471 | ||
|
|
1d66b43da1 | ||
|
|
75c4a4f253 | ||
|
|
6a9c95b3b1 | ||
|
|
c11a2d0081 | ||
|
|
43a13ff2c5 | ||
|
|
8454d163eb | ||
|
|
4a11f6a39f | ||
|
|
c63235cd3d | ||
|
|
f4ce196a34 | ||
|
|
e644b27201 | ||
|
|
d2b14582ee | ||
|
|
ca9194fd96 | ||
|
|
773d3ab13c | ||
|
|
a02a4a07a5 | ||
|
|
0a8625227f | ||
|
|
dc613aa2bf | ||
|
|
2008ac5663 | ||
|
|
da326f1925 | ||
|
|
71911cb1a7 | ||
|
|
31e7582216 | ||
|
|
91f63047e0 | ||
|
|
c9aa4938f4 | ||
|
|
b9e6a38037 | ||
|
|
eb054146d2 | ||
|
|
a780373cfc | ||
|
|
5ad90ccdb8 | ||
|
|
ef97d81ca8 | ||
|
|
c9f7b642c5 | ||
|
|
770962be3f | ||
|
|
d6c857f4a8 | ||
|
|
7b7ffb427d | ||
|
|
b80318d1b6 | ||
|
|
3997d69e49 | ||
|
|
4af41b33c0 | ||
|
|
0f8bbead66 | ||
|
|
804fcff5b6 | ||
|
|
d18e6920fc | ||
|
|
c95174ff11 | ||
|
|
c70329c22b | ||
|
|
34f69263a2 | ||
|
|
bf5056f8fe | ||
|
|
c47f5b06ba | ||
|
|
63055e3df2 | ||
|
|
8ee555140b | ||
|
|
859f78e986 | ||
|
|
366eebb68c | ||
|
|
25ce0b9aee | ||
|
|
376a7db545 | ||
|
|
f906ed741d | ||
|
|
ad152e62e5 | ||
|
|
7f3a61ef43 | ||
|
|
a7ba8c8213 | ||
|
|
b1f3d6d4c0 | ||
|
|
5707ae027f | ||
|
|
770208024d | ||
|
|
975a9f5344 | ||
|
|
a45008411e | ||
|
|
9091ffd1b3 | ||
|
|
089f161d1e | ||
|
|
e6d7131701 | ||
|
|
ed82170c2c | ||
|
|
3472d42cdd |
@@ -1 +1 @@
|
||||
.git
|
||||
.git
|
||||
|
||||
6
.env
Normal file
6
.env
Normal file
@@ -0,0 +1,6 @@
|
||||
GOTENBERG_VERSION=snapshot
|
||||
DOCKER_REGISTRY=gotenberg
|
||||
DOCKER_REPOSITORY=gotenberg
|
||||
DOCKERFILE=build/Dockerfile
|
||||
DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun
|
||||
DOCKER_BUILD_CONTEXT='.'
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [gulien]
|
||||
89
.github/actions/build-test-push/action.yml
vendored
Normal file
89
.github/actions/build-test-push/action.yml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
name: Build Test Push
|
||||
description: Build, test and push Docker images for a given platform
|
||||
author: Julien Neuhart
|
||||
|
||||
inputs:
|
||||
github_token:
|
||||
description: The GitHub token
|
||||
required: true
|
||||
default: ${{ github.token }}
|
||||
docker_hub_username:
|
||||
description: The Docker Hub username
|
||||
required: true
|
||||
docker_hub_password:
|
||||
description: The Docker Hub password
|
||||
required: true
|
||||
platform:
|
||||
description: linux/amd64, linux/386, linux/arm64, linux/arm/v7
|
||||
required: true
|
||||
version:
|
||||
description: Gotenberg version
|
||||
required: true
|
||||
skip_integrations_tests:
|
||||
description: Define whether to skip integration testing
|
||||
default: false
|
||||
alternate_repository:
|
||||
description: Alternate repository to push the tags to
|
||||
dry_run:
|
||||
description: Dry run this action
|
||||
|
||||
outputs:
|
||||
tags:
|
||||
description: Comma separated list of tag
|
||||
value: ${{ steps.build.outputs.tags }}
|
||||
tags_cloud_run:
|
||||
description: Comma separated list of Cloud Run tags (linux/amd64 only)
|
||||
value: ${{ steps.build.outputs.tags_cloud_run }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: inputs.docker_hub_username != ''
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.docker_hub_username }}
|
||||
password: ${{ inputs.docker_hub_password }}
|
||||
|
||||
- name: Build ${{ inputs.platform }}
|
||||
id: build
|
||||
shell: bash
|
||||
run: |
|
||||
.github/actions/build-test-push/build.sh \
|
||||
--version "${{ inputs.version }}" \
|
||||
--platform "${{ inputs.platform }}" \
|
||||
--alternate-repository "${{ inputs.alternate_repository }}" \
|
||||
--dry-run "${{ inputs.dry_run }}"
|
||||
|
||||
- name: Run integration tests
|
||||
if: inputs.skip_integrations_tests != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
.github/actions/build-test-push/test.sh \
|
||||
--version "${{ inputs.version }}" \
|
||||
--platform "${{ inputs.platform }}" \
|
||||
--alternate-repository "${{ inputs.alternate_repository }}" \
|
||||
--dry-run "${{ inputs.dry_run }}"
|
||||
|
||||
- name: Push
|
||||
if: inputs.docker_hub_username != ''
|
||||
shell: bash
|
||||
run: |
|
||||
.github/actions/build-test-push/push.sh \
|
||||
--tags "${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }}" \
|
||||
--dry-run "${{ inputs.dry_run }}"
|
||||
|
||||
- name: Outputs
|
||||
shell: bash
|
||||
run: |
|
||||
echo "tags=${{ steps.build.outputs.tags }}"
|
||||
echo "tags_cloud_run=${{ steps.build.outputs.tags_cloud_run }}"
|
||||
161
.github/actions/build-test-push/build.sh
vendored
Executable file
161
.github/actions/build-test-push/build.sh
vendored
Executable file
@@ -0,0 +1,161 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit early.
|
||||
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
|
||||
set -e
|
||||
|
||||
# Source dot env file.
|
||||
source .env
|
||||
|
||||
# Arguments.
|
||||
version=""
|
||||
platform=""
|
||||
alternate_repository=""
|
||||
dry_run=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--version)
|
||||
version="${2//v/}"
|
||||
shift 2
|
||||
;;
|
||||
--platform)
|
||||
platform="$2"
|
||||
shift 2
|
||||
;;
|
||||
--alternate-repository)
|
||||
alternate_repository="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Build and push 👷"
|
||||
echo
|
||||
|
||||
echo "Gotenberg version: $version"
|
||||
echo "Target platform: $platform"
|
||||
|
||||
if [ -n "$alternate_repository" ]; then
|
||||
DOCKER_REPOSITORY=$alternate_repository
|
||||
echo "⚠️ Using $alternate_repository for DOCKER_REPOSITORY"
|
||||
fi
|
||||
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
echo "🚧 Dry run"
|
||||
fi
|
||||
|
||||
# Build tags arrays.
|
||||
tags=()
|
||||
tags_cloud_run=()
|
||||
|
||||
IFS='/' read -ra arch <<< "$platform"
|
||||
IFS='.' read -ra semver <<< "$version"
|
||||
|
||||
if [ "${#semver[@]}" -eq 3 ]; then
|
||||
echo
|
||||
echo "Semver version detected"
|
||||
|
||||
major="${semver[0]}"
|
||||
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]}")
|
||||
|
||||
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")
|
||||
fi
|
||||
else
|
||||
echo
|
||||
echo "Non-semver version detected, fallback to $version"
|
||||
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-${arch[1]}")
|
||||
if [ "$platform" = "linux/amd64" ]; then
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-cloudrun")
|
||||
fi
|
||||
fi
|
||||
|
||||
tags_flags=()
|
||||
tags_cloud_run_flags=()
|
||||
|
||||
echo "Will use the following tags:"
|
||||
for tag in "${tags[@]}"; do
|
||||
tags_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_cloud_run[@]}"; do
|
||||
tags_cloud_run_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
echo
|
||||
|
||||
# Build images.
|
||||
run_cmd() {
|
||||
local cmd="$1"
|
||||
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
echo "🚧 Dry run - would run the following command:"
|
||||
echo "$cmd"
|
||||
echo
|
||||
else
|
||||
echo "⚙️ Running command:"
|
||||
echo "$cmd"
|
||||
eval "$cmd"
|
||||
echo
|
||||
fi
|
||||
}
|
||||
|
||||
join() {
|
||||
local delimiter="$1"
|
||||
shift
|
||||
local IFS="$delimiter"
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
|
||||
|
||||
cmd="docker buildx build \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_flags[*]} \
|
||||
-t $no_arch_tag \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
if [ "$platform" != "linux/amd64" ]; then
|
||||
echo "⚠️ Skip Cloud Run variant(s)"
|
||||
echo "✅ Done!"
|
||||
echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cmd="docker build \
|
||||
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
|
||||
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
${tags_cloud_run_flags[*]} \
|
||||
-f $DOCKERFILE_CLOUDRUN $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
echo "✅ Done!"
|
||||
echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
70
.github/actions/build-test-push/push.sh
vendored
Executable file
70
.github/actions/build-test-push/push.sh
vendored
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit early.
|
||||
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
|
||||
set -e
|
||||
|
||||
# Source dot env file.
|
||||
source .env
|
||||
|
||||
# Arguments.
|
||||
tags=""
|
||||
dry_run=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--tags)
|
||||
tags="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=$2
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Push tag(s) 📦"
|
||||
echo
|
||||
|
||||
echo "Tag(s) to push:"
|
||||
IFS=',' read -ra tags_to_push <<< "$tags"
|
||||
for tag in "${tags_to_push[@]}"; do
|
||||
echo "- $tag"
|
||||
done
|
||||
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
echo "🚧 Dry run"
|
||||
fi
|
||||
echo
|
||||
|
||||
# Push tags.
|
||||
run_cmd() {
|
||||
local cmd="$1"
|
||||
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
echo "🚧 Dry run - would run the following command:"
|
||||
echo "$cmd"
|
||||
echo
|
||||
else
|
||||
echo "⚙️ Running command:"
|
||||
echo "$cmd"
|
||||
eval "$cmd"
|
||||
echo
|
||||
fi
|
||||
}
|
||||
|
||||
for tag in "${tags_to_push[@]}"; do
|
||||
cmd="docker push $tag"
|
||||
run_cmd "$cmd"
|
||||
|
||||
echo "➡️ $tag pushed"
|
||||
echo
|
||||
done
|
||||
|
||||
echo "✅ Done!"
|
||||
exit 0
|
||||
79
.github/actions/build-test-push/test.sh
vendored
Executable file
79
.github/actions/build-test-push/test.sh
vendored
Executable file
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit early.
|
||||
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
|
||||
set -e
|
||||
|
||||
# Source dot env file.
|
||||
source .env
|
||||
|
||||
# Arguments.
|
||||
version=""
|
||||
platform=""
|
||||
alternate_repository=""
|
||||
dry_run=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--version)
|
||||
version="${2//v/}"
|
||||
shift 2
|
||||
;;
|
||||
--platform)
|
||||
platform="$2"
|
||||
shift 2
|
||||
;;
|
||||
--alternate-repository)
|
||||
alternate_repository="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Integration testing 🧪"
|
||||
echo
|
||||
|
||||
echo "Gotenberg version: $version"
|
||||
echo "Target platform: $platform"
|
||||
|
||||
repository=$DOCKER_REPOSITORY
|
||||
|
||||
if [ -n "$alternate_repository" ]; then
|
||||
echo "⚠️ Using $alternate_repository for DOCKER_REPOSITORY"
|
||||
repository=$alternate_repository
|
||||
fi
|
||||
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
echo "🚧 Dry run"
|
||||
fi
|
||||
echo
|
||||
|
||||
# Test image.
|
||||
run_cmd() {
|
||||
local cmd="$1"
|
||||
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
echo "🚧 Dry run - would run the following command:"
|
||||
echo "$cmd"
|
||||
echo
|
||||
else
|
||||
echo "⚙️ Running command:"
|
||||
echo "$cmd"
|
||||
eval "$cmd"
|
||||
echo
|
||||
fi
|
||||
}
|
||||
|
||||
cmd="make test-integration DOCKER_REPOSITORY=$repository GOTENBERG_VERSION=$version PLATFORM=$platform NO_CONCURRENCY=true"
|
||||
run_cmd "$cmd"
|
||||
|
||||
echo "✅ Done!"
|
||||
exit 0
|
||||
31
.github/actions/clean/action.yml
vendored
Normal file
31
.github/actions/clean/action.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Clean
|
||||
description: Clean tags from Docker Hub
|
||||
author: Julien Neuhart
|
||||
|
||||
inputs:
|
||||
docker_hub_username:
|
||||
description: The Docker Hub username
|
||||
required: true
|
||||
docker_hub_password:
|
||||
description: The Docker Hub password
|
||||
required: true
|
||||
tags:
|
||||
description: Comma separated list of tags to clean
|
||||
snapshot_version:
|
||||
description: Snapshot version to clean
|
||||
dry_run:
|
||||
description: Dry run this action
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Clean tags from Docker Hub
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ inputs.docker_hub_username }}
|
||||
DOCKERHUB_TOKEN: ${{ inputs.docker_hub_password }}
|
||||
shell: bash
|
||||
run: |
|
||||
.github/actions/clean/clean.sh \
|
||||
--tags "${{ inputs.tags }}" \
|
||||
--snapshot-version "${{ inputs.snapshot_version }}" \
|
||||
--dry-run "${{ inputs.dry_run }}"
|
||||
122
.github/actions/clean/clean.sh
vendored
Executable file
122
.github/actions/clean/clean.sh
vendored
Executable file
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit early.
|
||||
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
|
||||
set -e
|
||||
|
||||
# Source dot env file.
|
||||
source .env
|
||||
|
||||
# Arguments.
|
||||
tags=""
|
||||
snapshot_version=""
|
||||
dry_run=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--tags)
|
||||
tags="$2"
|
||||
shift 2
|
||||
;;
|
||||
--snapshot-version)
|
||||
snapshot_version="${2//v/}"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Clean tag(s) from Docker Hub 🧹"
|
||||
echo
|
||||
|
||||
IFS=',' read -ra tags_to_delete <<< "$tags"
|
||||
if [ -n "$snapshot_version" ]; then
|
||||
tags_to_delete+=("$DOCKER_REGISTRY/snapshot:$snapshot_version")
|
||||
tags_to_delete+=("$DOCKER_REGISTRY/snapshot:$snapshot_version-cloudrun")
|
||||
fi
|
||||
|
||||
echo "Will delete the following tag(s):"
|
||||
for tag in "${tags_to_delete[@]}"; do
|
||||
echo "- $tag"
|
||||
done
|
||||
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
echo "🚧 Dry run"
|
||||
fi
|
||||
echo
|
||||
|
||||
# Delete tags.
|
||||
base_url="https://hub.docker.com/v2"
|
||||
token=""
|
||||
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
token="placeholder"
|
||||
echo "🚧 Dry run - would call $base_url to get a token"
|
||||
echo
|
||||
else
|
||||
echo "🌐 Get token from $base_url"
|
||||
|
||||
readarray -t lines < <(
|
||||
curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"$DOCKERHUB_USERNAME\", \"password\":\"$DOCKERHUB_TOKEN\"}" \
|
||||
-w "\n%{http_code}" \
|
||||
"$base_url/users/login"
|
||||
)
|
||||
|
||||
http_code="${lines[-1]}"
|
||||
unset 'lines[-1]'
|
||||
json_body=$(printf "%s\n" "${lines[@]}")
|
||||
|
||||
if [ "$http_code" -ne "200" ]; then
|
||||
echo "❌ Wrong HTTP status - $http_code"
|
||||
echo "$json_body"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
token=$(jq -r '.token' <<< "$json_body")
|
||||
echo
|
||||
fi
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
echo "❌ No token from Docker Hub"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for tag in "${tags_to_delete[@]}"; do
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
echo "🚧 Dry run - would call $base_url to delete tag $tag"
|
||||
echo
|
||||
else
|
||||
echo "🌐 Delete tag $tag"
|
||||
IFS=':' read -ra tag_parts <<< "$tag"
|
||||
|
||||
readarray -t lines < <(
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-w "\n%{http_code}" \
|
||||
"$base_url/repositories/${tag_parts[0]}/tags/${tag_parts[1]}/"
|
||||
)
|
||||
|
||||
http_code="${lines[-1]}"
|
||||
unset 'lines[-1]'
|
||||
|
||||
if [ "$http_code" -ne "200" ] && [ "$http_code" -ne "204" ]; then
|
||||
echo "❌ Wrong HTTP status - $http_code"
|
||||
printf '%s\n' "${lines[@]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ Done!"
|
||||
exit 0
|
||||
48
.github/actions/merge/action.yml
vendored
Normal file
48
.github/actions/merge/action.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Merge
|
||||
description: Merge tags to single multi-platform tags
|
||||
author: Julien Neuhart
|
||||
|
||||
inputs:
|
||||
github_token:
|
||||
description: The GitHub token
|
||||
required: true
|
||||
default: ${{ github.token }}
|
||||
docker_hub_username:
|
||||
description: The Docker Hub username
|
||||
required: true
|
||||
docker_hub_password:
|
||||
description: The Docker Hub password
|
||||
required: true
|
||||
tags:
|
||||
description: Comma separated tags to merge
|
||||
required: true
|
||||
alternate_registry:
|
||||
description: Alternate registry to also push resulting tags
|
||||
dry_run:
|
||||
description: Dry run this action
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.docker_hub_username }}
|
||||
password: ${{ inputs.docker_hub_password }}
|
||||
|
||||
- name: Merge
|
||||
shell: bash
|
||||
run: |
|
||||
.github/actions/merge/merge.sh \
|
||||
--tags "${{ inputs.tags }}" \
|
||||
--alternate-registry "${{ inputs.alternate_registry }}" \
|
||||
--dry-run "${{ inputs.dry_run }}"
|
||||
107
.github/actions/merge/merge.sh
vendored
Executable file
107
.github/actions/merge/merge.sh
vendored
Executable file
@@ -0,0 +1,107 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit early.
|
||||
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
|
||||
set -e
|
||||
|
||||
# Source dot env file.
|
||||
source .env
|
||||
|
||||
# Arguments.
|
||||
tags=""
|
||||
alternate_registry=""
|
||||
dry_run=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--tags)
|
||||
tags="$2"
|
||||
shift 2
|
||||
;;
|
||||
--alternate-registry)
|
||||
alternate_registry="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=$2
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Merge tag(s) 👷"
|
||||
echo
|
||||
|
||||
echo "Tag(s) to merge:"
|
||||
IFS=',' read -ra tags_to_merge <<< "$tags"
|
||||
for tag in "${tags_to_merge[@]}"; do
|
||||
echo "- $tag"
|
||||
done
|
||||
|
||||
if [ -n "$alternate_registry" ]; then
|
||||
echo "⚠️ Will also push to $alternate_registry registry"
|
||||
fi
|
||||
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
echo "🚧 Dry run"
|
||||
fi
|
||||
echo
|
||||
|
||||
# Build merge map.
|
||||
declare -A merge_map
|
||||
|
||||
for tag in "${tags_to_merge[@]}"; do
|
||||
target_tag="${tag//-amd64/}"
|
||||
target_tag="${target_tag//-arm64/}"
|
||||
target_tag="${target_tag//-arm/}"
|
||||
target_tag="${target_tag//-386/}"
|
||||
|
||||
merge_map["$target_tag"]+="$tag "
|
||||
done
|
||||
|
||||
# Merge tags.
|
||||
run_cmd() {
|
||||
local cmd="$1"
|
||||
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
echo "🚧 Dry run - would run the following command:"
|
||||
echo "$cmd"
|
||||
echo
|
||||
else
|
||||
echo "⚙️ Running command:"
|
||||
echo "$cmd"
|
||||
eval "$cmd"
|
||||
echo
|
||||
fi
|
||||
}
|
||||
|
||||
for target in "${!merge_map[@]}"; do
|
||||
IFS=' ' read -ra source_tags <<< "${merge_map[$target]}"
|
||||
|
||||
cmd="docker buildx imagetools create \
|
||||
-t $target \
|
||||
${source_tags[*]}
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
echo "➡️ $target pushed"
|
||||
echo
|
||||
if [ -n "$alternate_registry" ]; then
|
||||
alternate_target="${target/$DOCKER_REGISTRY/$alternate_registry}"
|
||||
cmd="docker buildx imagetools create \
|
||||
-t $alternate_target \
|
||||
$target
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
echo "➡️ $alternate_target pushed"
|
||||
echo
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ Done!"
|
||||
exit 0
|
||||
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@@ -1,8 +1,18 @@
|
||||
version: 2
|
||||
updates:
|
||||
|
||||
# Maintain dependencies for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/build"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
6
.github/stale.yml
vendored
6
.github/stale.yml
vendored
@@ -1,7 +1,7 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 15
|
||||
daysUntilStale: 7
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
daysUntilClose: 3
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- bug
|
||||
@@ -15,4 +15,4 @@ markComment: >
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
closeComment: false
|
||||
|
||||
116
.github/workflows/continuous-delivery.yml
vendored
Normal file
116
.github/workflows/continuous-delivery.yml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
name: Continuous Delivery
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release_amd64:
|
||||
name: Release linux/amd64
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
uses: ./.github/actions/build-test-push
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
version: ${{ github.event.release.tag_name }}
|
||||
platform: linux/amd64
|
||||
skip_integrations_tests: true
|
||||
|
||||
release_386:
|
||||
name: Release linux/386
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
uses: ./.github/actions/build-test-push
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
version: ${{ github.event.release.tag_name }}
|
||||
platform: linux/386
|
||||
skip_integrations_tests: true
|
||||
|
||||
release_arm64:
|
||||
name: Release linux/arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
uses: ./.github/actions/build-test-push
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
version: ${{ github.event.release.tag_name }}
|
||||
platform: linux/arm64
|
||||
skip_integrations_tests: true
|
||||
|
||||
release_arm_v7:
|
||||
name: Release linux/arm/v7
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
uses: ./.github/actions/build-test-push
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
version: ${{ github.event.release.tag_name }}
|
||||
platform: linux/arm/v7
|
||||
skip_integrations_tests: true
|
||||
|
||||
merge_clean_release_tags:
|
||||
needs:
|
||||
- release_amd64
|
||||
- release_386
|
||||
- release_arm64
|
||||
- release_arm_v7
|
||||
name: Merge and clean release tags
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Merge
|
||||
uses: ./.github/actions/merge
|
||||
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_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Clean
|
||||
uses: ./.github/actions/clean
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
|
||||
325
.github/workflows/continuous-integration.yml
vendored
Normal file
325
.github/workflows/continuous-integration.yml
vendored
Normal file
@@ -0,0 +1,325 @@
|
||||
name: Continuous Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ (github.event_name == 'pull_request' && github.event.pull_request.number) || 'main' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint Golang codebase
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run linters
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: v2.1.6
|
||||
|
||||
lint-prettier:
|
||||
name: Lint non-Golang codebase
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm i
|
||||
|
||||
- name: Run linters
|
||||
run: make lint-prettier
|
||||
|
||||
test-unit:
|
||||
needs:
|
||||
- lint
|
||||
- lint-prettier
|
||||
name: Run unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run tests
|
||||
run: make test-unit
|
||||
|
||||
snapshot_amd64:
|
||||
if: github.event_name == 'pull_request'
|
||||
needs:
|
||||
- test-unit
|
||||
name: Snapshot linux/amd64
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
uses: ./.github/actions/build-test-push
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
version: pr-${{ github.event.pull_request.number }}
|
||||
platform: linux/amd64
|
||||
alternate_repository: snapshot
|
||||
|
||||
snapshot_386:
|
||||
if: github.event_name == 'pull_request'
|
||||
needs:
|
||||
- test-unit
|
||||
name: Snapshot linux/386
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
uses: ./.github/actions/build-test-push
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
version: pr-${{ github.event.pull_request.number }}
|
||||
platform: linux/386
|
||||
alternate_repository: snapshot
|
||||
|
||||
snapshot_arm64:
|
||||
if: github.event_name == 'pull_request'
|
||||
needs:
|
||||
- test-unit
|
||||
name: Snapshot linux/arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
uses: ./.github/actions/build-test-push
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
version: pr-${{ github.event.pull_request.number }}
|
||||
platform: linux/arm64
|
||||
alternate_repository: snapshot
|
||||
|
||||
snapshot_arm_v7:
|
||||
if: github.event_name == 'pull_request'
|
||||
needs:
|
||||
- test-unit
|
||||
name: Snapshot linux/arm/v7
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
uses: ./.github/actions/build-test-push
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
version: pr-${{ github.event.pull_request.number }}
|
||||
platform: linux/arm/v7
|
||||
alternate_repository: snapshot
|
||||
|
||||
merge_clean_snapshot_guard:
|
||||
needs:
|
||||
- snapshot_amd64
|
||||
- snapshot_386
|
||||
- snapshot_arm64
|
||||
- snapshot_arm_v7
|
||||
name: Secrets access check
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
continue: ${{ steps.check.outputs.continue }}
|
||||
steps:
|
||||
- name: Check
|
||||
id: check
|
||||
run: |
|
||||
if [ -n "${{ secrets.DOCKERHUB_USERNAME }}" ]; then
|
||||
echo "continue=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "continue=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
merge_clean_snapshot_tags:
|
||||
if: needs.merge_clean_snapshot_guard.outputs.continue == 'true'
|
||||
needs:
|
||||
- merge_clean_snapshot_guard
|
||||
- snapshot_amd64
|
||||
- snapshot_386
|
||||
- snapshot_arm64
|
||||
- snapshot_arm_v7
|
||||
name: Merge and clean snapshot tags
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Merge
|
||||
uses: ./.github/actions/merge
|
||||
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_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
|
||||
|
||||
- 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_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
|
||||
|
||||
edge_amd64:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- test-unit
|
||||
name: Edge linux/amd64
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
uses: ./.github/actions/build-test-push
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
version: edge
|
||||
platform: linux/amd64
|
||||
|
||||
edge_386:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- test-unit
|
||||
name: Edge linux/386
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
uses: ./.github/actions/build-test-push
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
version: edge
|
||||
platform: linux/386
|
||||
|
||||
edge_arm64:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- test-unit
|
||||
name: Edge linux/arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
uses: ./.github/actions/build-test-push
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
version: edge
|
||||
platform: linux/arm64
|
||||
|
||||
edge_arm_v7:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- test-unit
|
||||
name: Edge linux/arm/v7
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
uses: ./.github/actions/build-test-push
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
version: edge
|
||||
platform: linux/arm/v7
|
||||
|
||||
merge_clean_edge_tags:
|
||||
needs:
|
||||
- edge_amd64
|
||||
- edge_386
|
||||
- edge_arm64
|
||||
- edge_arm_v7
|
||||
name: Merge and clean edge tags
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Merge
|
||||
uses: ./.github/actions/merge
|
||||
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_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Clean
|
||||
uses: ./.github/actions/clean
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
|
||||
26
.github/workflows/continuous_delivery.yml
vendored
26
.github/workflows/continuous_delivery.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: Continuous Delivery
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release Docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to Docker Hub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push Docker image for release
|
||||
run: |
|
||||
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }}
|
||||
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REPOSITORY=thecodingmachine
|
||||
71
.github/workflows/continuous_integration.yml
vendored
71
.github/workflows/continuous_integration.yml
vendored
@@ -1,71 +0,0 @@
|
||||
name: Continuous Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
cache: false
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Run linters
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: v1.54.2
|
||||
|
||||
tests:
|
||||
needs:
|
||||
- Lint
|
||||
name: Tests
|
||||
# TODO: once arm64 actions are available, also run the tests on this architecture.
|
||||
# See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Build testing environment
|
||||
run: make build build-tests
|
||||
- name: Run tests
|
||||
run: |
|
||||
make tests-once
|
||||
bash <(curl -s https://codecov.io/bash)
|
||||
|
||||
multiarch_build:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- Tests
|
||||
name: Multi-arch build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to Docker Hub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push Docker image for main branch
|
||||
run: |
|
||||
make release GOTENBERG_VERSION=edge
|
||||
make release GOTENBERG_VERSION=edge DOCKER_REPOSITORY=thecodingmachine
|
||||
24
.github/workflows/pull-request-cleanup.yml
vendored
Normal file
24
.github/workflows/pull-request-cleanup.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Pull Request Cleanup
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: Cleanup Docker images
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cleanup
|
||||
uses: ./.github/actions/clean
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
snapshot_version: pr-${{ github.event.pull_request.number }}
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
/coverage.html
|
||||
/coverage.txt
|
||||
/TODO.txt
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
/node_modules/
|
||||
|
||||
@@ -1,36 +1,65 @@
|
||||
linters-settings:
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- prefix(github.com/gotenberg/gotenberg/v8)
|
||||
skip-generated: true
|
||||
custom-order: true
|
||||
|
||||
version: "2"
|
||||
run:
|
||||
issues-exit-code: 1
|
||||
tests: false
|
||||
linters:
|
||||
disable-all: true
|
||||
default: none
|
||||
enable:
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- copyloopvar
|
||||
- decorder
|
||||
- dogsled
|
||||
- dupl
|
||||
- dupword
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- gci
|
||||
- gofumpt
|
||||
- errname
|
||||
- exhaustive
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- importas
|
||||
- ineffassign
|
||||
- misspell
|
||||
- prealloc
|
||||
- promlinter
|
||||
- staticcheck
|
||||
- typecheck
|
||||
- testableexamples
|
||||
- tparallel
|
||||
- unconvert
|
||||
- unused
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
issues-exit-code: 1
|
||||
tests: false
|
||||
|
||||
output:
|
||||
format: 'colored-line-number'
|
||||
print-issued-lines: true
|
||||
print-linter-name: true
|
||||
- usetesting
|
||||
- wastedassign
|
||||
- whitespace
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- goimports
|
||||
settings:
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- prefix(github.com/gotenberg/gotenberg/v8)
|
||||
custom-order: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
1
.node-version
Normal file
1
.node-version
Normal file
@@ -0,0 +1 @@
|
||||
23.9.0
|
||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
build
|
||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-gherkin", "prettier-plugin-sh"],
|
||||
"escapeBackslashes": true
|
||||
}
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Julien Neuhart
|
||||
Copyright (c) Julien Neuhart
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
151
Makefile
151
Makefile
@@ -1,40 +1,36 @@
|
||||
include .env
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show the help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: it
|
||||
it: build build-tests ## Initialize the development environment
|
||||
|
||||
GOLANG_VERSION=1.21
|
||||
DOCKER_REPOSITORY=gotenberg
|
||||
GOTENBERG_VERSION=snapshot
|
||||
GOTENBERG_USER_GID=1001
|
||||
GOTENBERG_USER_UID=1001
|
||||
NOTO_COLOR_EMOJI_VERSION=v2.040 # See https://github.com/googlefonts/noto-emoji/releases.
|
||||
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
||||
GOLANGCI_LINT_VERSION=v1.54.2 # See https://github.com/golangci/golangci-lint/releases.
|
||||
@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
|
||||
docker build \
|
||||
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
|
||||
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
|
||||
--build-arg GOTENBERG_USER_GID=$(GOTENBERG_USER_GID) \
|
||||
--build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \
|
||||
--build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \
|
||||
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
|
||||
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
|
||||
-f build/Dockerfile .
|
||||
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
|
||||
-f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
|
||||
|
||||
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
|
||||
GOTENBERG_BUILD_DEBUG_DATA=true
|
||||
API_PORT=3000
|
||||
API_PORT_FROM_ENV=
|
||||
API_BIND_IP=
|
||||
API_START_TIMEOUT=30s
|
||||
API_TIMEOUT=30s
|
||||
API_ROOT_PATH=/
|
||||
API_BODY_LIMIT=
|
||||
API_ROOT_PATH="/"
|
||||
API_TRACE_HEADER=Gotenberg-Trace
|
||||
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-FROM-MAX-RETRY=4
|
||||
API-DISABLE-DOWNLOAD-FROM=false
|
||||
API_DISABLE_HEALTH_CHECK_LOGGING=false
|
||||
CHROMIUM_RESTART_AFTER=0
|
||||
API_ENABLE_DEBUG_ROUTE=false
|
||||
CHROMIUM_RESTART_AFTER=10
|
||||
CHROMIUM_MAX_QUEUE_SIZE=0
|
||||
CHROMIUM_AUTO_START=false
|
||||
CHROMIUM_START_TIMEOUT=20s
|
||||
CHROMIUM_INCOGNITO=false
|
||||
@@ -45,19 +41,26 @@ CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
|
||||
CHROMIUM_HOST_RESOLVER_RULES=
|
||||
CHROMIUM_PROXY_SERVER=
|
||||
CHROMIUM_ALLOW_LIST=
|
||||
CHROMIUM_DENY_LIST="^file:///[^tmp].*"
|
||||
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
|
||||
CHROMIUM_CLEAR_CACHE=false
|
||||
CHROMIUM_CLEAR_COOKIES=false
|
||||
CHROMIUM_DISABLE_JAVASCRIPT=false
|
||||
CHROMIUM_DISABLE_ROUTES=false
|
||||
LIBREOFFICE_RESTART_AFTER=10
|
||||
LIBREOFFICE_MAX_QUEUE_SIZE=0
|
||||
LIBREOFFICE_AUTO_START=false
|
||||
LIBREOFFICE_START_TIMEOUT=20s
|
||||
LIBREOFFICE_DISABLE_ROUTES=false
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=auto
|
||||
LOG_FIELDS_PREFIX=
|
||||
PDFENGINES_ENGINES=
|
||||
LOG_ENABLE_GCP_FIELDS=false
|
||||
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
||||
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
||||
PDFENGINES_FLATTEN_ENGINES=qpdf
|
||||
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
|
||||
PDFENGINES_READ_METADATA_ENGINES=exiftool
|
||||
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
||||
PDFENGINES_DISABLE_ROUTES=false
|
||||
PROMETHEUS_NAMESPACE=gotenberg
|
||||
PROMETHEUS_COLLECT_INTERVAL=1s
|
||||
@@ -77,18 +80,30 @@ WEBHOOK_DISABLE=false
|
||||
run: ## Start a Gotenberg container
|
||||
docker run --rm -it \
|
||||
-p $(API_PORT):$(API_PORT) \
|
||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
|
||||
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
|
||||
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
|
||||
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
|
||||
gotenberg \
|
||||
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
|
||||
--gotenberg-build-debug-data="$(GOTENBERG_BUILD_DEBUG_DATA)" \
|
||||
--api-port=$(API_PORT) \
|
||||
--api-port-from-env=$(API_PORT_FROM_ENV) \
|
||||
--api-bind-ip=$(API_BIND_IP) \
|
||||
--api-start-timeout=$(API_START_TIMEOUT) \
|
||||
--api-timeout=$(API_TIMEOUT) \
|
||||
--api-body-limit="$(API_BODY_LIMIT)" \
|
||||
--api-root-path=$(API_ROOT_PATH) \
|
||||
--api-trace-header=$(API_TRACE_HEADER) \
|
||||
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
|
||||
--api-download-from-allow-list=$(API-DOWNLOAD-FROM-ALLOW-LIST) \
|
||||
--api-download-from-deny-list=$(API-DOWNLOAD-FROM-DENY-LIST) \
|
||||
--api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \
|
||||
--api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \
|
||||
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
|
||||
--api-enable-debug-route=$(API_ENABLE_DEBUG_ROUTE) \
|
||||
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
|
||||
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
|
||||
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
|
||||
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
|
||||
--chromium-incognito=$(CHROMIUM_INCOGNITO) \
|
||||
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
|
||||
@@ -97,27 +112,34 @@ run: ## Start a Gotenberg container
|
||||
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
|
||||
--chromium-host-resolver-rules=$(CHROMIUM_HOST_RESOLVER_RULES) \
|
||||
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
|
||||
--chromium-allow-list=$(CHROMIUM_ALLOW_LIST) \
|
||||
--chromium-deny-list=$(CHROMIUM_DENY_LIST) \
|
||||
--chromium-allow-list="$(CHROMIUM_ALLOW_LIST)" \
|
||||
--chromium-deny-list="$(CHROMIUM_DENY_LIST)" \
|
||||
--chromium-clear-cache=$(CHROMIUM_CLEAR_CACHE) \
|
||||
--chromium-clear-cookies=$(CHROMIUM_CLEAR_COOKIES) \
|
||||
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
|
||||
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
|
||||
--libreoffice-restart-after=$(LIBREOFFICE_RESTART_AFTER) \
|
||||
--libreoffice-max-queue-size=$(LIBREOFFICE_MAX_QUEUE_SIZE) \
|
||||
--libreoffice-auto-start=$(LIBREOFFICE_AUTO_START) \
|
||||
--libreoffice-start-timeout=$(LIBREOFFICE_START_TIMEOUT) \
|
||||
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLE_ROUTES) \
|
||||
--log-level=$(LOG_LEVEL) \
|
||||
--log-format=$(LOG_FORMAT) \
|
||||
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
|
||||
--pdfengines-engines=$(PDFENGINES_ENGINES) \
|
||||
--log-enable-gcp-fields=$(LOG_ENABLE_GCP_FIELDS) \
|
||||
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
|
||||
--pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \
|
||||
--pdfengines-flatten-engines=$(PDFENGINES_FLATTEN_ENGINES) \
|
||||
--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
|
||||
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
|
||||
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_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-collect=$(PROMETHEUS_DISABLE_COLLECT) \
|
||||
--webhook-allow-list=$(WEBHOOK_ALLOW_LIST) \
|
||||
--webhook-deny-list=$(WEBHOOK_DENY_LIST) \
|
||||
--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) \
|
||||
@@ -126,51 +148,44 @@ run: ## Start a Gotenberg container
|
||||
--webhook-client-timeout=$(WEBHOOK_CLIENT_TIMEOUT) \
|
||||
--webhook-disable=$(WEBHOOK_DISABLE)
|
||||
|
||||
.PHONY: build-tests
|
||||
build-tests: ## Build the tests' Docker image
|
||||
docker build \
|
||||
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
|
||||
--build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \
|
||||
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
|
||||
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
|
||||
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
||||
-f test/Dockerfile .
|
||||
.PHONY: test-unit
|
||||
test-unit: ## Run unit tests
|
||||
go test -race ./...
|
||||
|
||||
.PHONY: tests
|
||||
tests: ## Start the testing environment
|
||||
docker run --rm -it \
|
||||
-v $(PWD):/tests \
|
||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
||||
bash
|
||||
PLATFORM=
|
||||
NO_CONCURRENCY=false
|
||||
|
||||
.PHONY: tests-once
|
||||
tests-once: ## Run the tests once (prefer the "tests" command while developing)
|
||||
docker run --rm \
|
||||
-v $(PWD):/tests \
|
||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
||||
gotest
|
||||
.PHONY: test-integration
|
||||
test-integration: ## Run integration tests
|
||||
go test -timeout 20m -tags=integration -v github.com/gotenberg/gotenberg/v8/test/integration -args \
|
||||
--gotenberg-docker-repository=$(DOCKER_REPOSITORY) \
|
||||
--gotenberg-version=$(GOTENBERG_VERSION) \
|
||||
--gotenberg-container-platform=$(PLATFORM) \
|
||||
--no-concurrency=$(NO_CONCURRENCY)
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Lint Golang codebase
|
||||
golangci-lint run
|
||||
|
||||
.PHONY: lint-prettier
|
||||
lint-prettier: ## Lint non-Golang codebase
|
||||
npx prettier --check .
|
||||
|
||||
.PHONY: lint-todo
|
||||
lint-todo: ## Find TODOs in Golang codebase
|
||||
golangci-lint run --no-config --disable-all --enable godox
|
||||
|
||||
# go install mvdan.cc/gofumpt@latest
|
||||
# go install github.com/daixiang0/gci@latest
|
||||
.PHONY: fmt
|
||||
fmt: ## Format the code and "optimize" the dependencies
|
||||
gofumpt -l -w .
|
||||
gci write -s standard -s default -s "prefix(github.com/gotenberg/gotenberg/v8)" --skip-generated --skip-vendor --custom-order .
|
||||
fmt: ## Format Golang codebase and "optimize" the dependencies
|
||||
golangci-lint fmt
|
||||
go mod tidy
|
||||
|
||||
.PHONY: prettify
|
||||
prettify: ## Format non-Golang codebase
|
||||
npx prettier --write .
|
||||
|
||||
# go install golang.org/x/tools/cmd/godoc@latest
|
||||
.PHONY: godoc
|
||||
godoc: ## Run a webserver with Gotenberg godoc
|
||||
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8)
|
||||
godoc -http=:6060
|
||||
|
||||
.PHONY: release
|
||||
release: ## Build the Gotenberg's Docker image for many platforms, then push it to a Docker repository
|
||||
./scripts/release.sh \
|
||||
$(GOLANG_VERSION) \
|
||||
$(GOTENBERG_VERSION) \
|
||||
$(GOTENBERG_USER_GID) \
|
||||
$(GOTENBERG_USER_UID) \
|
||||
$(NOTO_COLOR_EMOJI_VERSION) \
|
||||
$(PDFTK_VERSION) \
|
||||
$(DOCKER_REPOSITORY)
|
||||
|
||||
29
README.md
29
README.md
@@ -1,13 +1,22 @@
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
|
||||
<h3 align="center">Gotenberg</h3>
|
||||
<p align="center">A Docker-powered stateless API for PDF files</p>
|
||||
<p align="center">A containerized API for seamless PDF conversion</p>
|
||||
<p align="center">
|
||||
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
|
||||
<a href="https://hub.docker.com/r/thecodingmachine/gotenberg"><img alt="Total downloads (thecodingmachine/gotenberg)" src="https://img.shields.io/docker/pulls/thecodingmachine/gotenberg"></a>
|
||||
<a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml/badge.svg"></a>
|
||||
<a href="https://pkg.go.dev/github.com/gotenberg/gotenberg/v8"><img alt="Go Reference" src="https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/2996"><img src="https://trendshift.io/api/badge/repositories/2996" alt="gotenberg%2Fgotenberg | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Documentation</a> · <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Live Demo</a> 🔥</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
Gotenberg provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
|
||||
**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
|
||||
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
|
||||
|
||||
## Quick Start
|
||||
@@ -32,14 +41,14 @@ Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduct
|
||||
|
||||
<p align="center">
|
||||
<a href="https://thecodingmachine.com">
|
||||
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="429" height="210" />
|
||||
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="333" height="163" />
|
||||
</a>
|
||||
<a href="https://zolsec.com?utm_source=gotenberg_github&utm_medium=website" target="_blank">
|
||||
<img src="https://github.com/gotenberg/gotenberg/assets/8983173/707ccc97-a79b-4dcb-8fc8-6827366e5be3" alt="Zolsec Logo" width="333" height="163" />
|
||||
</a>
|
||||
<a href="https://pdfme.com?utm_source=gotenberg_github&utm_medium=website" target="_blank">
|
||||
<img src="https://github.com/user-attachments/assets/2a75dd40-ca18-4d34-acd5-5dd474595168" alt="pdfme Logo" width="333" height="163" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Badges
|
||||
|
||||
[](https://hub.docker.com/r/gotenberg/gotenberg)
|
||||
[](https://hub.docker.com/r/thecodingmachine/gotenberg)
|
||||
[](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml)
|
||||
[](https://pkg.go.dev/github.com/gotenberg/gotenberg/v8)
|
||||
[](https://codecov.io/gh/gotenberg/gotenberg)
|
||||
Sponsorships help maintain and improve Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️
|
||||
|
||||
30
SECURITY.md
30
SECURITY.md
@@ -2,25 +2,25 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Please ensure to keep your environment up-to-date and use only the latest version of Gotenberg.
|
||||
Please ensure to keep your environment up to date and use only the latest version of Gotenberg.
|
||||
Security updates and patches will be applied only to the most recent version.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Your help in identifying vulnerabilities in our project is much appreciated.
|
||||
Your help in identifying vulnerabilities in our project is much appreciated.
|
||||
We take all reports regarding security seriously.
|
||||
|
||||
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*.
|
||||
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 the subject of your email, please indicate that it's a security vulnerability report for Gotenberg.
|
||||
In your message, please include:
|
||||
|
||||
* A detailed description of the vulnerability.
|
||||
* The steps to reproduce the issue.
|
||||
* Any potential impact of the vulnerability on the users or system.
|
||||
- A detailed description of the vulnerability.
|
||||
- The steps to reproduce the issue.
|
||||
- Any potential impact of the vulnerability on the users or system.
|
||||
|
||||
Please remember that this process is done in a *'best-effort'* manner.
|
||||
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.
|
||||
|
||||
@@ -28,14 +28,14 @@ Thank you in advance for helping to keep our project safe!
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
Once we have received your vulnerability report, we will work to validate and reproduce the issue.
|
||||
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:
|
||||
|
||||
* 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.
|
||||
- Work on a fix and a release timeline.
|
||||
- Notify you when the fix has been implemented and released.
|
||||
- Credit you for discovering the vulnerability (unless you request anonymity).
|
||||
- Please note that we will do our best to keep you informed about the progress towards resolving the issue.
|
||||
|
||||
## Comments on this Policy
|
||||
|
||||
If you have suggestions on how this process could be improved, please submit a pull request.
|
||||
If you have suggestions on how this process could be improved, please submit a pull request.
|
||||
|
||||
249
build/Dockerfile
249
build/Dockerfile
@@ -1,15 +1,40 @@
|
||||
# ARG instructions do not create additional layers. Instead, next layers will
|
||||
# concatenate them. Also, we have to repeat ARG instructions in each build
|
||||
# stage that uses them.
|
||||
ARG GOLANG_VERSION
|
||||
ARG GOLANG_VERSION=1.24
|
||||
|
||||
# ----------------------------------------------
|
||||
# pdfcpu binary build stage
|
||||
# ----------------------------------------------
|
||||
# Note: this stage is required as pdfcpu does not release an armhf variant by
|
||||
# default.
|
||||
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
||||
|
||||
# See https://github.com/pdfcpu/pdfcpu/releases.
|
||||
ARG PDFCPU_VERSION=v0.8.1
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||
WORKDIR /home
|
||||
|
||||
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz &&\
|
||||
tar --strip-components=1 -xvzf pdfcpu.tar.gz
|
||||
|
||||
# Install module dependencies.
|
||||
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 &&\
|
||||
# Verify installation.
|
||||
./pdfcpu version
|
||||
|
||||
# ----------------------------------------------
|
||||
# Gotenberg binary build stage
|
||||
# ----------------------------------------------
|
||||
FROM golang:$GOLANG_VERSION AS binary-stage
|
||||
FROM golang:$GOLANG_VERSION AS gotenberg-binary-stage
|
||||
|
||||
ARG GOTENBERG_VERSION
|
||||
ENV CGO_ENABLED 0
|
||||
ARG GOTENBERG_VERSION=snapshot
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||
WORKDIR /home
|
||||
@@ -24,26 +49,77 @@ RUN go mod download &&\
|
||||
COPY cmd ./cmd
|
||||
COPY pkg ./pkg
|
||||
|
||||
RUN go build -o gotenberg -ldflags "-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
|
||||
|
||||
# ----------------------------------------------
|
||||
# Compress Golang binaries stage
|
||||
# ----------------------------------------------
|
||||
FROM debian:12-slim AS compress-go-binaries-stage
|
||||
|
||||
RUN \
|
||||
echo "deb http://deb.debian.org/debian bookworm-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 bookworm-backports upx-ucl
|
||||
|
||||
COPY --from=pdfcpu-binary-stage /home/pdfcpu /home/pdfcpu
|
||||
COPY --from=gotenberg-binary-stage /home/gotenberg /home/gotenberg
|
||||
|
||||
RUN \
|
||||
upx-ucl --best /home/pdfcpu &&\
|
||||
upx-ucl --best /home/gotenberg
|
||||
|
||||
# ----------------------------------------------
|
||||
# Custom JRE stage
|
||||
# Credits: https://github.com/jodconverter/docker-image-jodconverter-runtime
|
||||
# ----------------------------------------------
|
||||
FROM debian:12-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
|
||||
|
||||
# Note: jdeps helps finding which modules a JAR requires.
|
||||
# Currently only for PDFtk, as we don't rely on LibreOffice UNO Java SDK.
|
||||
ENV JAVA_MODULES=java.base,java.desktop,java.naming,java.sql
|
||||
|
||||
RUN jlink \
|
||||
--add-modules $JAVA_MODULES \
|
||||
--strip-debug \
|
||||
--no-man-pages \
|
||||
--no-header-files \
|
||||
--compress=2 \
|
||||
--output /custom-jre
|
||||
|
||||
# ----------------------------------------------
|
||||
# Base image stage
|
||||
# ----------------------------------------------
|
||||
FROM debian:12-slim AS base-image-stage
|
||||
|
||||
COPY --from=custom-jre-stage /custom-jre /opt/java
|
||||
|
||||
ENV PATH="/opt/java/bin:${PATH}"
|
||||
|
||||
# ----------------------------------------------
|
||||
# Final stage
|
||||
# ----------------------------------------------
|
||||
FROM debian:12-slim
|
||||
FROM base-image-stage
|
||||
|
||||
ARG GOTENBERG_VERSION
|
||||
ARG GOTENBERG_USER_GID
|
||||
ARG GOTENBERG_USER_UID
|
||||
ARG NOTO_COLOR_EMOJI_VERSION
|
||||
ARG PDFTK_VERSION
|
||||
ARG TMP_CHOMIUM_VERSION_ARMHF="116.0.5845.180-1~deb12u1"
|
||||
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.047
|
||||
# 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 Docker-powered stateless API for PDF files." \
|
||||
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"
|
||||
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"
|
||||
|
||||
RUN \
|
||||
# Create a non-root user.
|
||||
@@ -57,7 +133,8 @@ RUN \
|
||||
# Install system dependencies required for the next instructions or debugging.
|
||||
# Note: tini is a helper for reaping zombie processes.
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 default-jre-headless &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg=2.2.40-1.1 tini=0.19.0-1 python3=3.11.2-1+b1 &&\
|
||||
# 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.
|
||||
@@ -71,42 +148,43 @@ RUN \
|
||||
# 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 \
|
||||
culmus=0.133-1 \
|
||||
fonts-beng=2:1.3 \
|
||||
fonts-hosny-amiri=0.113-1 \
|
||||
fonts-lklug-sinhala=0.6-4 \
|
||||
fonts-lohit-guru=2.91.2-3 \
|
||||
fonts-lohit-knda=2.5.4-3 \
|
||||
fonts-samyak-gujr=1.2.2-6 \
|
||||
fonts-samyak-mlym=1.2.2-6 \
|
||||
fonts-samyak-taml=1.2.2-6 \
|
||||
fonts-sarai=1.0-3 \
|
||||
fonts-sil-abyssinica=2.100-3 \
|
||||
fonts-sil-padauk=5.000-3 \
|
||||
fonts-telu=2:1.3 \
|
||||
fonts-thai-tlwg=1:0.7.3-1 \
|
||||
ttf-wqy-zenhei \
|
||||
fonts-arphic-ukai \
|
||||
fonts-arphic-uming \
|
||||
fonts-ipafont-mincho \
|
||||
fonts-ipafont-gothic \
|
||||
fonts-unfonts-core \
|
||||
fonts-arphic-ukai=0.2.20080216.2-5 \
|
||||
fonts-arphic-uming=0.2.20080216.2-11 \
|
||||
fonts-ipafont-mincho=00303-23 \
|
||||
fonts-ipafont-gothic=00303-23 \
|
||||
fonts-unfonts-core=1:1.0.2-080608-18 \
|
||||
# LibreOffice recommends.
|
||||
fonts-crosextra-caladea \
|
||||
fonts-crosextra-carlito \
|
||||
fonts-dejavu \
|
||||
fonts-dejavu-extra \
|
||||
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 &&\
|
||||
fonts-crosextra-caladea=20200211-1 \
|
||||
fonts-crosextra-carlito=20220224-1 \
|
||||
fonts-dejavu=2.37-6 \
|
||||
fonts-dejavu-extra=2.37-6 \
|
||||
fonts-liberation=1:1.07.4-11 \
|
||||
fonts-liberation2=2.1.5-1 \
|
||||
fonts-linuxlibertine=5.3.0-6 \
|
||||
fonts-noto-cjk=1:20220127+repack1-1 \
|
||||
fonts-noto-core=20201225-1 \
|
||||
fonts-noto-mono=20201225-1 \
|
||||
fonts-noto-ui-core=20201225-1 \
|
||||
fonts-sil-gentium=20081126:1.03-4 \
|
||||
fonts-sil-gentium-basic=1.102-1.1 &&\
|
||||
rm -f ./ttf-mscorefonts-installer_3.8.1_all.deb &&\
|
||||
# Add Color and Black-and-White Noto emoji font.
|
||||
# Credits:
|
||||
@@ -120,40 +198,39 @@ RUN \
|
||||
# Install either Google Chrome stable on amd64 architecture or
|
||||
# Chromium on other architectures.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/328.
|
||||
# FIXME:
|
||||
# armhf is currently not working with the latest version of Chromium.
|
||||
# See: https://github.com/gotenberg/gotenberg/issues/709.
|
||||
/bin/bash -c \
|
||||
'set -e &&\
|
||||
if [[ "$(dpkg --print-architecture)" == "amd64" ]]; then \
|
||||
curl https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\
|
||||
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\
|
||||
mv /usr/bin/google-chrome-stable /usr/bin/chromium; \
|
||||
elif [[ "$(dpkg --print-architecture)" == "armhf" ]]; then \
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
|
||||
debsnap chromium-common "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
|
||||
debsnap chromium "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" "./binary-chromium/chromium_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
|
||||
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
|
||||
curl https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\
|
||||
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\
|
||||
mv /usr/bin/google-chrome-stable /usr/bin/chromium; \
|
||||
else \
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
|
||||
fi' &&\
|
||||
# Verify installation.
|
||||
chromium --version &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Set default characterset encoding to UTF-8.
|
||||
# See:
|
||||
# https://github.com/gotenberg/gotenberg/issues/104
|
||||
# https://github.com/gotenberg/gotenberg/issues/730
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
|
||||
RUN \
|
||||
# Install LibreOffice & unoconverter.
|
||||
echo "deb http://deb.debian.org/debian bookworm-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 bookworm-backports libreoffice &&\
|
||||
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.0.1/unoconv -o /usr/bin/unoconverter &&\
|
||||
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.1.1/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 &&\
|
||||
@@ -164,37 +241,51 @@ RUN \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
RUN \
|
||||
# Install PDFtk & QPDF (PDF engines).
|
||||
# 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 &&\
|
||||
echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
|
||||
printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
|
||||
chmod +x /usr/bin/pdftk &&\
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf=11.3.0-1+deb12u1 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 the Gotenberg binary from the binary stage.
|
||||
COPY --from=binary-stage /home/gotenberg /usr/bin/
|
||||
# Copy the Golang binaries.
|
||||
COPY --from=compress-go-binaries-stage /home/pdfcpu /usr/bin/
|
||||
COPY --from=compress-go-binaries-stage /home/gotenberg /usr/bin/
|
||||
|
||||
# Environment variables required by modules or else.
|
||||
ENV CHROMIUM_BIN_PATH /usr/bin/chromium
|
||||
ENV LIBREOFFICE_BIN_PATH /usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH /usr/bin/unoconverter
|
||||
ENV PDFTK_BIN_PATH /usr/bin/pdftk
|
||||
ENV QPDF_BIN_PATH /usr/bin/qpdf
|
||||
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
|
||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||
ENV PDFTK_BIN_PATH=/usr/bin/pdftk
|
||||
ENV QPDF_BIN_PATH=/usr/bin/qpdf
|
||||
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
||||
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
ARG DOCKER_REGISTRY
|
||||
ARG DOCKER_REPOSITORY
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION
|
||||
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ package gotenbergcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -23,7 +25,7 @@ const banner = `
|
||||
\___/\___/\__/\__/_//_/_.__/\__/_/ \_, /
|
||||
/___/
|
||||
|
||||
A Docker-powered stateless API for PDF files.
|
||||
A containerized API for seamless PDF conversion.
|
||||
Version: %s
|
||||
-------------------------------------------------------
|
||||
`
|
||||
@@ -35,10 +37,12 @@ var Version = "snapshot"
|
||||
// Run starts the Gotenberg application. Call this in the main of your program.
|
||||
func Run() {
|
||||
fmt.Printf(banner, Version)
|
||||
gotenberg.Version = Version
|
||||
|
||||
// Create the root FlagSet and adds the modules flags to it.
|
||||
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
|
||||
fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration")
|
||||
fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed")
|
||||
|
||||
descriptors := gotenberg.GetModuleDescriptors()
|
||||
var modsInfo string
|
||||
@@ -49,14 +53,42 @@ func Run() {
|
||||
|
||||
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo)
|
||||
|
||||
// Parse the flags...
|
||||
// Parse the flags.
|
||||
err := fs.Parse(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// ...and create a wrapper around those.
|
||||
// Override their values if the corresponding environment variables are
|
||||
// set.
|
||||
fs.VisitAll(func(f *flag.Flag) {
|
||||
envName := strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_"))
|
||||
val, ok := os.LookupEnv(envName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
sliceVal, ok := f.Value.(flag.SliceValue)
|
||||
if ok {
|
||||
// We don't want to append the values (default pflag behavior).
|
||||
items := strings.Split(val, ",")
|
||||
err = sliceVal.Replace(items)
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = f.Value.Set(val)
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
// Create a wrapper around our flags.
|
||||
parsedFlags := gotenberg.ParsedFlags{FlagSet: fs}
|
||||
|
||||
// Get the graceful shutdown duration.
|
||||
@@ -75,7 +107,6 @@ func Run() {
|
||||
go func(app gotenberg.App) {
|
||||
id := app.(gotenberg.Module).Descriptor().ID
|
||||
err = app.Start()
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] starting %s: %s\n", id, err)
|
||||
os.Exit(1)
|
||||
@@ -108,6 +139,11 @@ func Run() {
|
||||
}(l.(gotenberg.SystemLogger))
|
||||
}
|
||||
|
||||
if parsedFlags.MustBool("gotenberg-build-debug-data") {
|
||||
// Build the debug data.
|
||||
gotenberg.BuildDebug(ctx)
|
||||
}
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
|
||||
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) or SIGTERM (Kubernetes).
|
||||
@@ -138,7 +174,9 @@ func Run() {
|
||||
id := app.(gotenberg.Module).Descriptor().ID
|
||||
|
||||
err = app.Stop(gracefulShutdownCtx)
|
||||
if err != nil {
|
||||
if errors.Is(err, gotenberg.ErrCancelGracefulShutdownContext) {
|
||||
cancel()
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("stopping %s: %w", id, err)
|
||||
}
|
||||
|
||||
|
||||
149
go.mod
149
go.mod
@@ -1,65 +1,130 @@
|
||||
module github.com/gotenberg/gotenberg/v8
|
||||
|
||||
go 1.21
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/alexliesenfeld/health v0.8.0
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20240116100315-4a0ec5e4c400
|
||||
github.com/chromedp/chromedp v0.9.3
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/uuid v1.5.0
|
||||
github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 // indirect
|
||||
github.com/barasher/go-exiftool v1.10.0
|
||||
github.com/chromedp/cdproto v0.0.0-20250416210000-d7e4d624041a
|
||||
github.com/chromedp/chromedp v0.13.6
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/labstack/echo/v4 v4.11.4
|
||||
github.com/labstack/echo/v4 v4.13.3
|
||||
github.com/labstack/gommon v0.4.2
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mholt/archiver/v3 v3.5.1
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/nwaples/rardecode v1.1.3 // indirect
|
||||
github.com/pdfcpu/pdfcpu v0.6.0
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/prometheus/client_golang v1.18.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/ulikunitz/xz v0.5.11 // indirect
|
||||
github.com/spf13/pflag v1.0.6
|
||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||
go.uber.org/multierr v1.11.0
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/crypto v0.18.0 // indirect
|
||||
golang.org/x/image v0.15.0 // indirect
|
||||
golang.org/x/net v0.20.0
|
||||
golang.org/x/sync v0.6.0
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/term v0.16.0
|
||||
golang.org/x/text v0.14.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/sync v0.14.0
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/term v0.32.0
|
||||
golang.org/x/text v0.25.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cucumber/godog v0.15.0
|
||||
github.com/dlclark/regexp2 v1.11.5
|
||||
github.com/docker/docker v28.1.1+incompatible
|
||||
github.com/docker/go-connections v0.5.0
|
||||
github.com/mholt/archives v0.1.2
|
||||
github.com/shirou/gopsutil/v4 v4.25.4
|
||||
github.com/testcontainers/testcontainers-go v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.1 // indirect
|
||||
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/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
|
||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||
github.com/bodgit/sevenzip v1.6.0 // indirect
|
||||
github.com/bodgit/windows v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect
|
||||
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/distribution/reference 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.8.2 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.3.2 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/gofrs/uuid v4.4.0+incompatible // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||
github.com/hhrutter/tiff v1.0.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/minio/minlz v1.0.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.1.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.1.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.46.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.63.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sorairolake/lzip-go v0.3.7 // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
github.com/therootcompany/xz v1.0.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
google.golang.org/protobuf v1.32.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
610
go.sum
610
go.sum
@@ -1,161 +1,561 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
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.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk=
|
||||
github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
||||
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 h1:8PmGpDEZl9yDpcdEr6Odf23feCxK3LNUNMxjXg41pZQ=
|
||||
github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
|
||||
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/cdproto v0.0.0-20240116100315-4a0ec5e4c400 h1:mHR3reslmE6J351eW8TgB/BPT+B9OzMxLe7dPa5WYSQ=
|
||||
github.com/chromedp/cdproto v0.0.0-20240116100315-4a0ec5e4c400/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.9.3 h1:Wq58e0dZOdHsxaj9Owmfcf+ibtpYN1N0FWVbaxa/esg=
|
||||
github.com/chromedp/chromedp v0.9.3/go.mod h1:NipeUkUcuzIdFbBP8eNNvl9upcceOfWzoJn6cRe4ksA=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
||||
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
||||
github.com/bodgit/sevenzip v1.6.0 h1:a4R0Wu6/P1o1pP/3VV++aEOcyeBxeO/xE2Y9NSTrr6A=
|
||||
github.com/bodgit/sevenzip v1.6.0/go.mod h1:zOBh9nJUof7tcrlqJFv1koWRrhz3LbDbUNngkuZxLMc=
|
||||
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
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-20250416210000-d7e4d624041a h1:TVjyMfX22UgV/jP0A4UwrrCmI9zfvp6ZS5UBZMy3HDw=
|
||||
github.com/chromedp/cdproto v0.0.0-20250416210000-d7e4d624041a/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||
github.com/chromedp/chromedp v0.13.6 h1:xlNunMyzS5bu3r/QKrb3fzX6ow3WBQ6oao+J65PGZxk=
|
||||
github.com/chromedp/chromedp v0.13.6/go.mod h1:h8GPP6ZtLMLsU8zFbTcb7ZDGCvCy8j/vRoFmRltQx9A=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
|
||||
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
|
||||
github.com/cucumber/godog v0.15.0 h1:51AL8lBXF3f0cyA5CV4TnJFCTHpgiy+1x1Hb3TtZUmo=
|
||||
github.com/cucumber/godog v0.15.0/go.mod h1:FX3rzIDybWABU4kuIXLZ/qtqEe1Ac5RdXmqvACJOces=
|
||||
github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI=
|
||||
github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s=
|
||||
github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I=
|
||||
github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY=
|
||||
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
|
||||
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
|
||||
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
|
||||
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||
github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0=
|
||||
github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg=
|
||||
github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
|
||||
github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
|
||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
|
||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
|
||||
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/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-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
|
||||
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
||||
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/mholt/archives v0.1.2 h1:UBSe5NfYKHI1sy+S5dJsEsG9jsKKk8NJA4HCC+xTI4A=
|
||||
github.com/mholt/archives v0.1.2/go.mod h1:D7QzTHgw3ctfS6wgOO9dN+MFgdZpbksGCxprUOwZWDs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/minio/minlz v1.0.0 h1:Kj7aJZ1//LlTP1DM8Jm7lNKvvJS2m74gyyXXn3+uJWQ=
|
||||
github.com/minio/minlz v1.0.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
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.1.1 h1:OJaYalXdliBUXPmC8CZGQ7oZDxzX1/5mQmgn0/GASew=
|
||||
github.com/nwaples/rardecode/v2 v2.1.1/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pdfcpu/pdfcpu v0.6.0 h1:z4kARP5bcWa39TTYMcN/kjBnm7MvhTWjXgeYmkdAGMI=
|
||||
github.com/pdfcpu/pdfcpu v0.6.0/go.mod h1:kmpD0rk8YnZj0l3qSeGBlAB+XszHUgNv//ORH/E7EYo=
|
||||
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
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/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
|
||||
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.46.0 h1:doXzt5ybi1HBKpsZOL0sSkaNHJJqkyfEWZGGqqScV0Y=
|
||||
github.com/prometheus/common v0.46.0/go.mod h1:Tp0qkxpb9Jsg54QMe+EAmqXkSV7Evdy1BTn+g2pa/hQ=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
|
||||
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
|
||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw=
|
||||
github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sorairolake/lzip-go v0.3.7 h1:vP2uiD/NoklLyzYMdgOWkZME0ulkSfVTTE4MNRKCwNs=
|
||||
github.com/sorairolake/lzip-go v0.3.7/go.mod h1:THOHr0FlNVCw2eOIEE9shFJAG1QxQg/pf2XUPAmNIqg=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=
|
||||
github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM=
|
||||
github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw=
|
||||
github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
|
||||
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
|
||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
|
||||
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
|
||||
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f h1:2yNACc1O40tTnrsbk9Cv6oxiW8pxI/pXj0wRtdlYmgY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f/go.mod h1:Uy9bTZJqmfrw2rIBxgGLnamc78euZULUBrLZ9XTITKI=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
|
||||
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
|
||||
183
package-lock.json
generated
Normal file
183
package-lock.json
generated
Normal file
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"name": "gotenberg",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"prettier": "3.5.3",
|
||||
"prettier-plugin-gherkin": "^3.1.2",
|
||||
"prettier-plugin-sh": "^0.17.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@cucumber/gherkin": {
|
||||
"version": "27.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-27.0.0.tgz",
|
||||
"integrity": "sha512-j5rCsjqzRiC3iVTier3sa0kzyNbkcAmF7xr7jKnyO7qDeK3Z8Ye1P3KSVpeQRMY+KCDJ3WbTDdyxH0FwfA/fIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cucumber/messages": ">=19.1.4 <=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-22.0.0.tgz",
|
||||
"integrity": "sha512-EuaUtYte9ilkxcKmfqGF9pJsHRUU0jwie5ukuZ/1NPTuHS1LxHPsGEODK17RPRbZHOFhqybNzG2rHAwThxEymg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/uuid": "9.0.1",
|
||||
"class-transformer": "0.5.1",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"uuid": "9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cucumber/gherkin/node_modules/@types/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cucumber/gherkin/node_modules/uuid": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
|
||||
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@cucumber/messages": {
|
||||
"version": "23.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-23.0.0.tgz",
|
||||
"integrity": "sha512-2ZWKNnikNMBnle3P3cgy+fgzhABrY4oBbiWp9wcI8ANdT4LlYRN6jQ6j/9CQGTDGRfkyRtr/5VkYq7q24XCsuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/uuid": "9.0.6",
|
||||
"class-transformer": "0.5.1",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"uuid": "9.0.1"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^v12.20.0 || ^14.13.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.6.tgz",
|
||||
"integrity": "sha512-BT2Krtx4xaO6iwzwMFUYvWBWkV2pr37zD68Vmp1CDV196MzczBRxuEpD6Pr395HAgebC/co7hOphs53r8V7jew==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/class-transformer": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
|
||||
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-gherkin": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-gherkin/-/prettier-plugin-gherkin-3.1.2.tgz",
|
||||
"integrity": "sha512-LgehRNKCAqZsoE/yblmqXYfMpEK2mhVWQKNj/TFzeLR+0rhFGHqq3RlYcYhPgzbr0n6w2Bv+0Gm1yxNdzkdacw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cucumber/gherkin": "^27.0.0",
|
||||
"@cucumber/messages": "^23.0.0",
|
||||
"prettier": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-sh": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.17.4.tgz",
|
||||
"integrity": "sha512-aAVKXZ7GTEMZdZsIPSwMwddwPvt2ibMbRGd4OJAP0G7QoeYZV+mPNg2Oln3R53sZ4PVjeAA7Xzi/PuI0QlHHfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reteps/dockerfmt": "^0.3.5",
|
||||
"sh-syntax": "^0.5.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/unts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prettier": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
|
||||
"integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/sh-syntax": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.5.7.tgz",
|
||||
"integrity": "sha512-74m9dt91konrF5+m0kASugzi37VxKsnTJQ6yvdDZu3IijG5/vIZpImP6FadsJLWNt2X2YD0VaTwW5W7Ox7mFVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/sh-syntax"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
package.json
Normal file
7
package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"prettier": "3.5.3",
|
||||
"prettier-plugin-gherkin": "^3.1.2",
|
||||
"prettier-plugin-sh": "^0.17.4"
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func (cmd *Cmd) Start() error {
|
||||
}
|
||||
|
||||
// Wait waits for the command to complete. It should be called when using the
|
||||
// Start method, so that the command does not leak zombies.
|
||||
// Start method so that the command does not leak zombies.
|
||||
func (cmd *Cmd) Wait() error {
|
||||
err := cmd.process.Wait()
|
||||
if err != nil {
|
||||
@@ -84,7 +84,7 @@ func (cmd *Cmd) Wait() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exec executes the command and wait for its completion or until the context
|
||||
// Exec executes the command and waits for its completion or until the context
|
||||
// is done. In any case, it kills the unix process and all its children.
|
||||
func (cmd *Cmd) Exec() (int, error) {
|
||||
if cmd.ctx == nil {
|
||||
@@ -156,7 +156,7 @@ func (cmd *Cmd) pipeOutput() error {
|
||||
r := bufio.NewReader(reader)
|
||||
defer func(reader io.ReadCloser) {
|
||||
err := reader.Close()
|
||||
if err != nil {
|
||||
if err != nil && !strings.Contains(err.Error(), "file already closed") {
|
||||
logger.Error(fmt.Sprintf("close reader: %s", err))
|
||||
}
|
||||
}(reader)
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestCommand(t *testing.T) {
|
||||
cmd := Command(zap.NewNop(), "foo")
|
||||
if !cmd.process.SysProcAttr.Setpgid {
|
||||
t.Error("expected cmd.process.SysProcAttr.Setpgid to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
ctx context.Context
|
||||
expectCommandContextError bool
|
||||
}{
|
||||
{
|
||||
scenario: "nominal behavior",
|
||||
ctx: context.Background(),
|
||||
expectCommandContextError: false,
|
||||
},
|
||||
{
|
||||
scenario: "nil context",
|
||||
ctx: nil,
|
||||
expectCommandContextError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo")
|
||||
|
||||
if err == nil && !cmd.process.SysProcAttr.Setpgid {
|
||||
t.Fatal("expected cmd.process.SysProcAttr.Setpgid to be true")
|
||||
}
|
||||
|
||||
if !tc.expectCommandContextError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectCommandContextError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Start(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
cmd *Cmd
|
||||
expectStartError bool
|
||||
}{
|
||||
{
|
||||
scenario: "nominal behavior",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
expectStartError: false,
|
||||
},
|
||||
{
|
||||
scenario: "start error",
|
||||
cmd: Command(zap.NewNop(), "foo"),
|
||||
expectStartError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.cmd.Start()
|
||||
|
||||
if !tc.expectStartError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectStartError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Wait(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
cmd *Cmd
|
||||
expectWaitError bool
|
||||
}{
|
||||
{
|
||||
scenario: "nominal behavior",
|
||||
cmd: func() *Cmd {
|
||||
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
return cmd
|
||||
}(),
|
||||
expectWaitError: false,
|
||||
},
|
||||
{
|
||||
scenario: "wait error",
|
||||
cmd: func() *Cmd {
|
||||
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
err = cmd.Kill()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
return cmd
|
||||
}(),
|
||||
expectWaitError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.cmd.Wait()
|
||||
|
||||
if !tc.expectWaitError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectWaitError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Exec(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
cmd *Cmd
|
||||
timeout time.Duration
|
||||
expectExecError bool
|
||||
}{
|
||||
{
|
||||
scenario: "nominal behavior",
|
||||
cmd: func() *Cmd {
|
||||
cmd, err := CommandContext(context.Background(), zap.NewNop(), "echo", "Hello", "World")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from CommandContext(), but got: %v", err)
|
||||
}
|
||||
return cmd
|
||||
}(),
|
||||
expectExecError: false,
|
||||
},
|
||||
{
|
||||
scenario: "nil context",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
expectExecError: true,
|
||||
},
|
||||
{
|
||||
scenario: "start error",
|
||||
cmd: func() *Cmd {
|
||||
cmd, err := CommandContext(context.Background(), zap.NewNop(), "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
return cmd
|
||||
}(),
|
||||
expectExecError: true,
|
||||
},
|
||||
{
|
||||
scenario: "context done",
|
||||
cmd: Command(zap.NewNop(), "sleep", "2"),
|
||||
timeout: time.Duration(1) * time.Second,
|
||||
expectExecError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.timeout > 0 {
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout)
|
||||
defer cancel()
|
||||
|
||||
tc.cmd.ctx = ctx
|
||||
}
|
||||
|
||||
_, err := tc.cmd.Exec()
|
||||
|
||||
if !tc.expectExecError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectExecError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_pipeOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
cmd *Cmd
|
||||
run bool
|
||||
expectPipeOutputError bool
|
||||
}{
|
||||
{
|
||||
scenario: "nominal behavior",
|
||||
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
|
||||
run: true,
|
||||
expectPipeOutputError: false,
|
||||
},
|
||||
{
|
||||
scenario: "no debug, no pipe",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
run: false,
|
||||
expectPipeOutputError: false,
|
||||
},
|
||||
{
|
||||
scenario: "stdout already piped",
|
||||
cmd: func() *Cmd {
|
||||
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
|
||||
_, err := cmd.process.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
return cmd
|
||||
}(),
|
||||
run: false,
|
||||
expectPipeOutputError: true,
|
||||
},
|
||||
{
|
||||
scenario: "stderr already piped",
|
||||
cmd: func() *Cmd {
|
||||
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
|
||||
_, err := cmd.process.StderrPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
return cmd
|
||||
}(),
|
||||
run: false,
|
||||
expectPipeOutputError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.cmd.pipeOutput()
|
||||
|
||||
if tc.run {
|
||||
errStart := tc.cmd.process.Start()
|
||||
if errStart != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !tc.expectPipeOutputError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectPipeOutputError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Kill(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
cmd *Cmd
|
||||
}{
|
||||
{
|
||||
scenario: "nominal behavior",
|
||||
cmd: func() *Cmd {
|
||||
cmd := Command(zap.NewNop(), "sleep", "60")
|
||||
err := cmd.process.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
return cmd
|
||||
}(),
|
||||
},
|
||||
{
|
||||
scenario: "no process",
|
||||
cmd: &Cmd{logger: zap.NewNop()},
|
||||
},
|
||||
{
|
||||
scenario: "process already killed",
|
||||
cmd: func() *Cmd {
|
||||
cmd := Command(zap.NewNop(), "sleep", "60")
|
||||
err := cmd.process.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
err = cmd.Kill()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
return cmd
|
||||
}(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.cmd.Kill()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// Context is a struct which helps to initialize modules. When provisioning, a
|
||||
// Context is a struct that helps to initialize modules. When provisioning, a
|
||||
// module may use the context to get other modules that it needs internally.
|
||||
type Context struct {
|
||||
flags ParsedFlags
|
||||
@@ -58,7 +58,7 @@ func (ctx *Context) Module(kind interface{}) (interface{}, error) {
|
||||
return mods[0], nil
|
||||
}
|
||||
|
||||
// Modules returns the list of modules which satisfies the requested interface.
|
||||
// Modules return the list of modules which satisfies the requested interface.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// mods, _ := ctx.Modules(new(ModuleInterface))
|
||||
|
||||
@@ -5,23 +5,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewContext(t *testing.T) {
|
||||
if NewContext(ParsedFlags{}, nil) == nil {
|
||||
t.Error("expected a non-nil value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_ParsedFlags(t *testing.T) {
|
||||
ctx := NewContext(ParsedFlags{}, nil)
|
||||
|
||||
actual := ctx.ParsedFlags()
|
||||
expect := ParsedFlags{}
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_Module(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
|
||||
68
pkg/gotenberg/debug.go
Normal file
68
pkg/gotenberg/debug.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// DebugInfo gathers data for debugging.
|
||||
type DebugInfo struct {
|
||||
Version string `json:"version"`
|
||||
Architecture string `json:"architecture"`
|
||||
Modules []string `json:"modules"`
|
||||
ModulesAdditionalData map[string]map[string]interface{} `json:"modules_additional_data"`
|
||||
Flags map[string]interface{} `json:"flags"`
|
||||
}
|
||||
|
||||
// BuildDebug builds the debug data from modules.
|
||||
func BuildDebug(ctx *Context) {
|
||||
debugMu.Lock()
|
||||
defer debugMu.Unlock()
|
||||
|
||||
debug = &DebugInfo{
|
||||
Version: Version,
|
||||
Architecture: runtime.GOARCH,
|
||||
Modules: make([]string, len(ctx.moduleInstances)),
|
||||
ModulesAdditionalData: make(map[string]map[string]interface{}),
|
||||
Flags: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
i := 0
|
||||
for ID, mod := range ctx.moduleInstances {
|
||||
debug.Modules[i] = ID
|
||||
i++
|
||||
|
||||
debuggable, ok := mod.(Debuggable)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
debug.ModulesAdditionalData[ID] = debuggable.Debug()
|
||||
}
|
||||
|
||||
sort.Sort(AlphanumericSort(debug.Modules))
|
||||
|
||||
ctx.ParsedFlags().VisitAll(func(f *flag.Flag) {
|
||||
debug.Flags[f.Name] = f.Value.String()
|
||||
})
|
||||
}
|
||||
|
||||
// Debug returns the debug data.
|
||||
func Debug() DebugInfo {
|
||||
debugMu.Lock()
|
||||
defer debugMu.Unlock()
|
||||
|
||||
if debug == nil {
|
||||
return DebugInfo{}
|
||||
}
|
||||
|
||||
return *debug
|
||||
}
|
||||
|
||||
var (
|
||||
debug *DebugInfo
|
||||
debugMu sync.Mutex
|
||||
)
|
||||
72
pkg/gotenberg/debug_test.go
Normal file
72
pkg/gotenberg/debug_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestBuildDebug(t *testing.T) {
|
||||
if !reflect.DeepEqual(Debug(), DebugInfo{}) {
|
||||
t.Errorf("Debug() should return empty debug data")
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
|
||||
fs.String("foo", "bar", "Set foo")
|
||||
ctx := NewContext(ParsedFlags{
|
||||
FlagSet: fs,
|
||||
}, func() []ModuleDescriptor {
|
||||
mod1 := &struct {
|
||||
ModuleMock
|
||||
}{}
|
||||
mod1.DescriptorMock = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod1 }}
|
||||
}
|
||||
mod2 := &struct {
|
||||
ModuleMock
|
||||
DebuggableMock
|
||||
}{}
|
||||
mod2.DescriptorMock = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "bar", New: func() Module { return mod2 }}
|
||||
}
|
||||
mod2.DebugMock = func() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"foo": "bar",
|
||||
}
|
||||
}
|
||||
|
||||
return []ModuleDescriptor{mod1.Descriptor(), mod2.Descriptor()}
|
||||
}())
|
||||
|
||||
// Load modules.
|
||||
_, err := ctx.Modules(new(Module))
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
// Build debug data.
|
||||
BuildDebug(ctx)
|
||||
|
||||
expect := DebugInfo{
|
||||
Version: Version,
|
||||
Architecture: runtime.GOARCH,
|
||||
Modules: []string{
|
||||
"bar",
|
||||
"foo",
|
||||
},
|
||||
ModulesAdditionalData: map[string]map[string]interface{}{
|
||||
"bar": {
|
||||
"foo": "bar",
|
||||
},
|
||||
},
|
||||
Flags: map[string]interface{}{
|
||||
"foo": "bar",
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(expect, Debug()) {
|
||||
t.Errorf("expected '%+v', bug got '%+v'", expect, Debug())
|
||||
}
|
||||
}
|
||||
35
pkg/gotenberg/env.go
Normal file
35
pkg/gotenberg/env.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// StringEnv retrieves the value of the environment variable named by the key.
|
||||
// If the variable is present in the environment and not empty, the value is
|
||||
// returned.
|
||||
func StringEnv(key string) (string, error) {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("environment variable '%s' does not exist", key)
|
||||
}
|
||||
if val == "" {
|
||||
return "", fmt.Errorf("environment variable '%s' is empty", key)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// IntEnv relies on [StringEnv] and converts the values if it exists and is not
|
||||
// empty.
|
||||
func IntEnv(key string) (int, error) {
|
||||
val, err := StringEnv(key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
intVal, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get int value of environment variable '%s': %w", key, err)
|
||||
}
|
||||
return intVal, nil
|
||||
}
|
||||
134
pkg/gotenberg/env_test.go
Normal file
134
pkg/gotenberg/env_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStringEnv(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
key string
|
||||
setEnv func()
|
||||
expectVal string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "non-existing environment variable",
|
||||
key: "NON_EXISTING",
|
||||
expectVal: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "empty environment variable",
|
||||
key: "EMPTY_STRING",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("EMPTY_STRING", "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
key: "EXISTING_STRING_VALUE",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("EXISTING_STRING_VALUE", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: "foo",
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.setEnv != nil {
|
||||
tc.setEnv()
|
||||
}
|
||||
|
||||
val, err := StringEnv(tc.key)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectVal != val {
|
||||
t.Errorf("expected value '%s' but got '%s'", tc.expectVal, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntEnv(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
key string
|
||||
setEnv func()
|
||||
expectVal int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "empty environment variable",
|
||||
key: "EMPTY_INT",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("EMPTY_INT", "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "non-integer value",
|
||||
key: "NON_INTEGER",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("NON_INTEGER", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
key: "EXISTING_INT_VALUE",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("EXISTING_INT_VALUE", "123")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: 123,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.setEnv != nil {
|
||||
tc.setEnv()
|
||||
}
|
||||
|
||||
val, err := IntEnv(tc.key)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectVal != val {
|
||||
t.Errorf("expected value %d but got %d", tc.expectVal, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
53
pkg/gotenberg/filter.go
Normal file
53
pkg/gotenberg/filter.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
)
|
||||
|
||||
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
|
||||
var ErrFiltered = errors.New("value filtered")
|
||||
|
||||
// FilterDeadline checks if given value is allowed and not denied according to
|
||||
// regex patterns. It returns a [context.DeadlineExceeded] if it takes too long
|
||||
// to process.
|
||||
func FilterDeadline(allowed, denied *regexp2.Regexp, s string, deadline time.Time) error {
|
||||
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
|
||||
if allowed.String() != "" {
|
||||
allow := regexp2.MustCompile(allowed.String(), 0)
|
||||
allow.MatchTimeout = time.Until(deadline)
|
||||
|
||||
ok, err := allow.MatchString(s)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
return fmt.Errorf("'%s' cannot handle '%s': %w", allow.String(), s, err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", s, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
if denied.String() != "" {
|
||||
deny := regexp2.MustCompile(denied.String(), 0)
|
||||
deny.MatchTimeout = time.Until(deadline)
|
||||
|
||||
ok, err := deny.MatchString(s)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
return fmt.Errorf("'%s' cannot handle '%s': %w", deny.String(), s, err)
|
||||
}
|
||||
if ok {
|
||||
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
83
pkg/gotenberg/filter_test.go
Normal file
83
pkg/gotenberg/filter_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
)
|
||||
|
||||
func TestFilterDeadline(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
allowed *regexp2.Regexp
|
||||
denied *regexp2.Regexp
|
||||
s string
|
||||
deadline time.Time
|
||||
expectError bool
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
scenario: "DeadlineExceeded (allowed)",
|
||||
allowed: regexp2.MustCompile("foo", 0),
|
||||
denied: regexp2.MustCompile("", 0),
|
||||
s: "foo",
|
||||
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
||||
expectError: true,
|
||||
expectedError: context.DeadlineExceeded,
|
||||
},
|
||||
{
|
||||
scenario: "ErrFiltered (allowed)",
|
||||
allowed: regexp2.MustCompile("foo", 0),
|
||||
denied: regexp2.MustCompile("", 0),
|
||||
s: "bar",
|
||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||
expectError: true,
|
||||
expectedError: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "DeadlineExceeded (denied)",
|
||||
allowed: regexp2.MustCompile("", 0),
|
||||
denied: regexp2.MustCompile("foo", 0),
|
||||
s: "foo",
|
||||
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
||||
expectError: true,
|
||||
expectedError: context.DeadlineExceeded,
|
||||
},
|
||||
{
|
||||
scenario: "ErrFiltered (denied)",
|
||||
allowed: regexp2.MustCompile("", 0),
|
||||
denied: regexp2.MustCompile("foo", 0),
|
||||
s: "foo",
|
||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||
expectError: true,
|
||||
expectedError: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
allowed: regexp2.MustCompile("", 0),
|
||||
denied: regexp2.MustCompile("", 0),
|
||||
s: "foo",
|
||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline)
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected an error but got none")
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/labstack/gommon/bytes"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
@@ -168,50 +168,54 @@ func (f *ParsedFlags) MustDeprecatedDuration(deprecated string, newName string)
|
||||
return f.MustDuration(newName)
|
||||
}
|
||||
|
||||
// MustHumanReadableBytesString returns the human-readable bytes string of a
|
||||
// flag given by name.
|
||||
// MustHumanReadableBytes returns the human-readable bytes string of a flag
|
||||
// given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustHumanReadableBytesString(name string) string {
|
||||
func (f *ParsedFlags) MustHumanReadableBytes(name string) int64 {
|
||||
val, err := f.GetString(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
_, err = bytes.Parse(val)
|
||||
if val == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
b, err := bytes.Parse(val)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return val
|
||||
return b
|
||||
}
|
||||
|
||||
// MustDeprecatedHumanReadableBytesString returns the human-readable bytes
|
||||
// string of a deprecated flag if it was explicitly set or the human-readable
|
||||
// bytes string of the new flag.
|
||||
// MustDeprecatedHumanReadableBytes returns the human-readable bytes of a
|
||||
// deprecated flag if it was explicitly set or the human-readable bytes string
|
||||
// of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedHumanReadableBytesString(deprecated string, newName string) string {
|
||||
func (f *ParsedFlags) MustDeprecatedHumanReadableBytes(deprecated string, newName string) int64 {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustHumanReadableBytesString(deprecated)
|
||||
return f.MustHumanReadableBytes(deprecated)
|
||||
}
|
||||
|
||||
return f.MustHumanReadableBytesString(newName)
|
||||
return f.MustHumanReadableBytes(newName)
|
||||
}
|
||||
|
||||
// MustRegexp returns the regular expression of a flag given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustRegexp(name string) *regexp.Regexp {
|
||||
func (f *ParsedFlags) MustRegexp(name string) *regexp2.Regexp {
|
||||
val, err := f.GetString(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return regexp.MustCompile(val)
|
||||
return regexp2.MustCompile(val, 0)
|
||||
}
|
||||
|
||||
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if
|
||||
// it was explicitly set or the regular expression of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp.Regexp {
|
||||
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp2.Regexp {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustRegexp(deprecated)
|
||||
}
|
||||
|
||||
@@ -644,10 +644,11 @@ func TestParsedFlags_MustDeprecatedDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
|
||||
func TestParsedFlags_MustHumanReadableBytes(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "1MB", "")
|
||||
fs.String("bar", "1MB", "")
|
||||
fs.String("qux", "", "")
|
||||
|
||||
err := fs.Parse([]string{"--foo=1GB", "--bar=foo"})
|
||||
if err != nil {
|
||||
@@ -671,6 +672,11 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
scenario: "success: empty value",
|
||||
name: "qux",
|
||||
expectPanic: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.expectPanic {
|
||||
@@ -689,31 +695,31 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustHumanReadableBytesString(tc.name)
|
||||
parsedFlags.MustHumanReadableBytes(tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) {
|
||||
func TestParsedFlags_MustDeprecatedHumanReadableBytes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawFlags []string
|
||||
expectValue string
|
||||
expectValue int64
|
||||
}{
|
||||
{
|
||||
scenario: "deprecated flag value",
|
||||
rawFlags: []string{"--foo=1MB"},
|
||||
expectValue: "1MB",
|
||||
expectValue: 1000000,
|
||||
},
|
||||
{
|
||||
scenario: "non-deprecated flag value",
|
||||
rawFlags: []string{"--bar=2MB"},
|
||||
expectValue: "2MB",
|
||||
expectValue: 2000000,
|
||||
},
|
||||
{
|
||||
scenario: "deprecated flag value > non-deprecated flag value",
|
||||
rawFlags: []string{"--foo=1MB", "--bar=2MB"},
|
||||
expectValue: "1MB",
|
||||
expectValue: 1000000,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
@@ -728,9 +734,9 @@ func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedHumanReadableBytesString("foo", "bar")
|
||||
actual := parsedFlags.MustDeprecatedHumanReadableBytes("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("expected '%s' but got '%s'", tc.expectValue, actual)
|
||||
t.Errorf("expected %d but got %d", tc.expectValue, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,18 +7,50 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// MkdirAll defines the method signature for create a directory. Implement this
|
||||
// interface if you don't want to rely on [os.MkdirAll], notably for testing
|
||||
// purpose.
|
||||
type MkdirAll interface {
|
||||
// MkdirAll uses the same signature as [os.MkdirAll].
|
||||
MkdirAll(path string, perm os.FileMode) error
|
||||
}
|
||||
|
||||
// OsMkdirAll implements the [MkdirAll] interface with [os.MkdirAll].
|
||||
type OsMkdirAll struct{}
|
||||
|
||||
// MkdirAll is a wrapper around [os.MkdirAll].
|
||||
func (o *OsMkdirAll) MkdirAll(path string, perm os.FileMode) error { return os.MkdirAll(path, perm) }
|
||||
|
||||
// PathRename defines the method signature for renaming files. Implement this
|
||||
// interface if you don't want to rely on [os.Rename], notably for testing
|
||||
// purposes.
|
||||
type PathRename interface {
|
||||
// Rename uses the same signature as [os.Rename].
|
||||
Rename(oldpath, newpath string) error
|
||||
}
|
||||
|
||||
// OsPathRename implements the [PathRename] interface with [os.Rename].
|
||||
type OsPathRename struct{}
|
||||
|
||||
// Rename is a wrapper around [os.Rename].
|
||||
func (o *OsPathRename) Rename(oldpath, newpath string) error {
|
||||
return os.Rename(oldpath, newpath)
|
||||
}
|
||||
|
||||
// FileSystem provides utilities for managing temporary directories. It creates
|
||||
// unique directory names based on UUIDs to ensure isolation of temporary files
|
||||
// for different modules.
|
||||
type FileSystem struct {
|
||||
workingDir string
|
||||
mkdirAll MkdirAll
|
||||
}
|
||||
|
||||
// NewFileSystem initializes a new [FileSystem] instance with a unique working
|
||||
// directory.
|
||||
func NewFileSystem() *FileSystem {
|
||||
func NewFileSystem(mkdirAll MkdirAll) *FileSystem {
|
||||
return &FileSystem{
|
||||
workingDir: uuid.NewString(),
|
||||
mkdirAll: mkdirAll,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +76,16 @@ func (fs *FileSystem) NewDirPath() string {
|
||||
func (fs *FileSystem) MkdirAll() (string, error) {
|
||||
path := fs.NewDirPath()
|
||||
|
||||
err := os.MkdirAll(path, 0o755)
|
||||
err := fs.mkdirAll.MkdirAll(path, 0o755)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create directory %s: %w", path, err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ MkdirAll = (*OsMkdirAll)(nil)
|
||||
_ PathRename = (*OsPathRename)(nil)
|
||||
)
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileSystem_WorkingDir(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
dirName := fs.WorkingDir()
|
||||
|
||||
if dirName == "" {
|
||||
t.Error("expected directory name but got empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_WorkingDirPath(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
expectedPath := fmt.Sprintf("%s/%s", os.TempDir(), fs.WorkingDir())
|
||||
|
||||
if fs.WorkingDirPath() != expectedPath {
|
||||
t.Errorf("expected path '%s' but got '%s'", expectedPath, fs.WorkingDirPath())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_NewDirPath(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
newDir := fs.NewDirPath()
|
||||
expectedPrefix := fs.WorkingDirPath()
|
||||
|
||||
if !strings.HasPrefix(newDir, expectedPrefix) {
|
||||
t.Errorf("expected new directory to start with '%s' but got '%s'", expectedPrefix, newDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_MkdirAll(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
|
||||
newPath, err := fs.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
_, err = os.Stat(newPath)
|
||||
if os.IsNotExist(err) {
|
||||
t.Errorf("expected directory '%s' to exist but it doesn't", newPath)
|
||||
}
|
||||
|
||||
err = os.RemoveAll(fs.WorkingDirPath())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up but got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GarbageCollect scans the root path and deletes files or directories with
|
||||
// names containing specific substrings.
|
||||
func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string) error {
|
||||
// names containing specific substrings and before a given expiration time.
|
||||
func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error {
|
||||
logger = logger.Named("gc")
|
||||
|
||||
// To make sure that the next Walk method stays on
|
||||
@@ -36,7 +37,7 @@ func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string)
|
||||
}
|
||||
|
||||
for _, substr := range includeSubstr {
|
||||
if strings.Contains(info.Name(), substr) || path == substr {
|
||||
if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) {
|
||||
err := os.RemoveAll(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("garbage collect '%s': %w", path, err)
|
||||
|
||||
@@ -3,7 +3,9 @@ package gotenberg
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
@@ -26,31 +28,31 @@ func TestGarbageCollect(t *testing.T) {
|
||||
{
|
||||
scenario: "remove include substrings",
|
||||
rootPath: func() string {
|
||||
path := fmt.Sprintf("%s/a_directory", os.TempDir())
|
||||
p := fmt.Sprintf("%s/a_directory", os.TempDir())
|
||||
|
||||
err := os.MkdirAll(path, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/a_foo_file", path), []byte{1}, 0o755)
|
||||
err := os.MkdirAll(p, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/a_bar_file", path), []byte{1}, 0o755)
|
||||
err = os.WriteFile(fmt.Sprintf("%s/a_foo_file", p), []byte{1}, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/a_baz_file", path), []byte{1}, 0o755)
|
||||
err = os.WriteFile(fmt.Sprintf("%s/a_bar_file", p), []byte{1}, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
err = os.WriteFile(fmt.Sprintf("%s/a_baz_file", p), []byte{1}, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return p
|
||||
}(),
|
||||
includeSubstr: []string{"foo", fmt.Sprintf("%s/a_directory/a_bar_file", os.TempDir())},
|
||||
includeSubstr: []string{"foo", path.Join(os.TempDir(), "/a_directory/a_bar_file")},
|
||||
expectError: false,
|
||||
expectExists: []string{"a_baz_file"},
|
||||
expectNotExists: []string{"a_foo_file", "a_bar_file"},
|
||||
@@ -64,7 +66,7 @@ func TestGarbageCollect(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr)
|
||||
err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr, time.Now())
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
@@ -79,18 +81,18 @@ func TestGarbageCollect(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, name := range tc.expectNotExists {
|
||||
path := fmt.Sprintf("%s/%s", tc.rootPath, name)
|
||||
_, err = os.Stat(path)
|
||||
p := fmt.Sprintf("%s/%s", tc.rootPath, name)
|
||||
_, err = os.Stat(p)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("expected '%s' not to exist but it does: %v", path, err)
|
||||
t.Errorf("expected '%s' not to exist but it does: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range tc.expectExists {
|
||||
path := fmt.Sprintf("%s/%s", tc.rootPath, name)
|
||||
_, err = os.Stat(path)
|
||||
p := fmt.Sprintf("%s/%s", tc.rootPath, name)
|
||||
_, err = os.Stat(p)
|
||||
if os.IsNotExist(err) {
|
||||
t.Errorf("expected '%s' to exist but it does not: %v", path, err)
|
||||
t.Errorf("expected '%s' to exist but it does not: %v", p, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package gotenberg
|
||||
|
||||
import "go.uber.org/zap"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// LoggerProvider is an interface for a module that supplies a method for
|
||||
// creating a [zap.Logger] instance for use by other modules.
|
||||
@@ -12,3 +17,41 @@ import "go.uber.org/zap"
|
||||
type LoggerProvider interface {
|
||||
Logger(mod Module) (*zap.Logger, error)
|
||||
}
|
||||
|
||||
// LeveledLogger is a wrapper around a [zap.Logger] so that it may be used by a
|
||||
// [retryablehttp.Client].
|
||||
type LeveledLogger struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewLeveledLogger instantiates a [LeveledLogger].
|
||||
func NewLeveledLogger(logger *zap.Logger) *LeveledLogger {
|
||||
return &LeveledLogger{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Error logs a message at the error level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Error(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Warn logs a message at the warning level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Info logs a message at the info level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Info(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Debug logs a message at the debug level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ retryablehttp.LeveledLogger = (*LeveledLogger)(nil)
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -33,20 +34,50 @@ func (mod *ValidatorMock) Validate() error {
|
||||
return mod.ValidateMock()
|
||||
}
|
||||
|
||||
type DebuggableMock struct {
|
||||
DebugMock func() map[string]interface{}
|
||||
}
|
||||
|
||||
func (mod *DebuggableMock) Debug() map[string]interface{} {
|
||||
return mod.DebugMock()
|
||||
}
|
||||
|
||||
// PdfEngineMock is a mock for the [PdfEngine] interface.
|
||||
//
|
||||
//nolint:dupl
|
||||
type PdfEngineMock struct {
|
||||
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||
SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||
FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
|
||||
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return engine.MergeMock(ctx, logger, inputPaths, outputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||
return engine.SplitMock(ctx, logger, mode, inputPath, outputDirPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
|
||||
return engine.FlattenMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
|
||||
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return engine.ReadMetadataMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return engine.WriteMetadataMock(ctx, logger, metadata, inputPath)
|
||||
}
|
||||
|
||||
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
||||
type PdfEngineProviderMock struct {
|
||||
PdfEngineMock func() (PdfEngine, error)
|
||||
@@ -127,6 +158,24 @@ func (provider *MetricsProviderMock) Metrics() ([]Metric, error) {
|
||||
return provider.MetricsMock()
|
||||
}
|
||||
|
||||
// MkdirAllMock is a mock for the [MkdirAll] interface.
|
||||
type MkdirAllMock struct {
|
||||
MkdirAllMock func(path string, perm os.FileMode) error
|
||||
}
|
||||
|
||||
func (mkdirAll *MkdirAllMock) MkdirAll(path string, perm os.FileMode) error {
|
||||
return mkdirAll.MkdirAllMock(path, perm)
|
||||
}
|
||||
|
||||
// PathRenameMock is a mock for the [PathRename] interface.
|
||||
type PathRenameMock struct {
|
||||
RenameMock func(oldpath, newpath string) error
|
||||
}
|
||||
|
||||
func (rename *PathRenameMock) Rename(oldpath, newpath string) error {
|
||||
return rename.RenameMock(oldpath, newpath)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ Module = (*ModuleMock)(nil)
|
||||
@@ -137,4 +186,6 @@ var (
|
||||
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
|
||||
_ LoggerProvider = (*LoggerProviderMock)(nil)
|
||||
_ MetricsProvider = (*MetricsProviderMock)(nil)
|
||||
_ MkdirAll = (*MkdirAllMock)(nil)
|
||||
_ PathRename = (*PathRenameMock)(nil)
|
||||
)
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestModuleMock(t *testing.T) {
|
||||
mock := &ModuleMock{
|
||||
DescriptorMock: func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module {
|
||||
return nil
|
||||
}}
|
||||
},
|
||||
}
|
||||
|
||||
if mock.Descriptor().ID != "foo" {
|
||||
t.Errorf("expected ID '%s' from ModuleMock.Descriptor, but got '%s'", "foo", mock.Descriptor().ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionerMock(t *testing.T) {
|
||||
mock := &ProvisionerMock{
|
||||
ProvisionMock: func(*Context) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Provision(&Context{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProvisionerMock.Provision, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorMock(t *testing.T) {
|
||||
mock := &ValidatorMock{
|
||||
ValidateMock: func() error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Validate()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ValidatorMock.Validate, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFEngineMock(t *testing.T) {
|
||||
mock := &PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Merge(context.Background(), zap.NewNop(), nil, "")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from PdfEngineMock.Merge, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.Convert(context.Background(), zap.NewNop(), PdfFormats{}, "", "")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFEngineProviderMock(t *testing.T) {
|
||||
mock := &PdfEngineProviderMock{
|
||||
PdfEngineMock: func() (PdfEngine, error) {
|
||||
return new(PdfEngineMock), nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.PdfEngine()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from PdfEngineProviderMock.PdfEngine, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMock(t *testing.T) {
|
||||
mock := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
return nil
|
||||
},
|
||||
StopMock: func(logger *zap.Logger) error {
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *zap.Logger) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProcessMock.Start, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.Stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProcessMock.Stop, but got: %v", err)
|
||||
}
|
||||
|
||||
healthy := mock.Healthy(zap.NewNop())
|
||||
if !healthy {
|
||||
t.Error("expected true from ProcessMock.Healthy, but got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisorMock(t *testing.T) {
|
||||
mock := &ProcessSupervisorMock{
|
||||
LaunchMock: func() error {
|
||||
return nil
|
||||
},
|
||||
ShutdownMock: func() error {
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func() bool {
|
||||
return true
|
||||
},
|
||||
RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
return nil
|
||||
},
|
||||
ReqQueueSizeMock: func() int64 {
|
||||
return 0
|
||||
},
|
||||
RestartsCountMock: func() int64 {
|
||||
return 0
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Launch()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProcessSupervisorMock.Launch, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.Shutdown()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProcessSupervisorMock.Shutdown, but got: %v", err)
|
||||
}
|
||||
|
||||
healthy := mock.Healthy()
|
||||
if !healthy {
|
||||
t.Error("expected true from ProcessSupervisorMock.Healthy, but got false")
|
||||
}
|
||||
|
||||
err = mock.Run(context.TODO(), zap.NewNop(), nil)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProcessSupervisorMock.Run, but got: %v", err)
|
||||
}
|
||||
|
||||
size := mock.ReqQueueSize()
|
||||
if size != 0 {
|
||||
t.Errorf("expected 0 from ProcessSupervisorMock.ReqQueueSize, but got: %d", size)
|
||||
}
|
||||
|
||||
restarts := mock.RestartsCount()
|
||||
if restarts != 0 {
|
||||
t.Errorf("expected 0 from ProcessSupervisorMock.RestartsCount, but got: %d", restarts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerProviderMock(t *testing.T) {
|
||||
mock := &LoggerProviderMock{
|
||||
LoggerMock: func(mod Module) (*zap.Logger, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.Logger(new(ModuleMock))
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from LoggerProviderMock.Logger, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsProviderMock(t *testing.T) {
|
||||
mock := &MetricsProviderMock{
|
||||
MetricsMock: func() ([]Metric, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.Metrics()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from MetricsProviderMock.Metrics, but got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,12 @@ type SystemLogger interface {
|
||||
SystemMessages() []string
|
||||
}
|
||||
|
||||
// Debuggable is a module interface for modules which want to provide
|
||||
// additional debug data.
|
||||
type Debuggable interface {
|
||||
Debug() map[string]interface{}
|
||||
}
|
||||
|
||||
// MustRegisterModule registers a module.
|
||||
//
|
||||
// To register a module, create an init() method in the module main go file:
|
||||
|
||||
@@ -12,11 +12,43 @@ var (
|
||||
// PdfEngine interface is not supported by its current implementation.
|
||||
ErrPdfEngineMethodNotSupported = errors.New("method not supported")
|
||||
|
||||
// ErrPdfSplitModeNotSupported is returned when the Split method of the
|
||||
// PdfEngine interface does not support a requested PDF split mode.
|
||||
ErrPdfSplitModeNotSupported = errors.New("split mode not supported")
|
||||
|
||||
// ErrPdfFormatNotSupported is returned when the Convert method of the
|
||||
// PdfEngine interface does not support a requested PDF format conversion.
|
||||
ErrPdfFormatNotSupported = errors.New("PDF format not supported")
|
||||
|
||||
// ErrPdfEngineMetadataValueNotSupported is returned when a metadata value
|
||||
// is not supported.
|
||||
ErrPdfEngineMetadataValueNotSupported = errors.New("metadata value not supported")
|
||||
)
|
||||
|
||||
const (
|
||||
// SplitModeIntervals represents a mode where a PDF is split at specific
|
||||
// intervals.
|
||||
SplitModeIntervals string = "intervals"
|
||||
|
||||
// SplitModePages represents a mode where a PDF is split at specific page
|
||||
// ranges.
|
||||
SplitModePages string = "pages"
|
||||
)
|
||||
|
||||
// SplitMode gathers the data required to split a PDF into multiple parts.
|
||||
type SplitMode struct {
|
||||
// Mode is either "intervals" or "pages".
|
||||
Mode string
|
||||
|
||||
// Span is either the intervals or the page ranges to extract, depending on
|
||||
// the selected mode.
|
||||
Span string
|
||||
|
||||
// Unify specifies whether to put extracted pages into a single file or as
|
||||
// many files as there are page ranges. Only works with "pages" mode.
|
||||
Unify bool
|
||||
}
|
||||
|
||||
const (
|
||||
// PdfA1a represents the PDF/A-1a format.
|
||||
PdfA1a string = "PDF/A-1a"
|
||||
@@ -54,16 +86,33 @@ type PdfFormats struct {
|
||||
}
|
||||
|
||||
// PdfEngine provides an interface for operations on PDFs. Implementations
|
||||
// can utilize various tools like PDFtk, or implement functionality directly in
|
||||
// can use various tools like PDFtk, or implement functionality directly in
|
||||
// Go.
|
||||
//
|
||||
//nolint:dupl
|
||||
type PdfEngine interface {
|
||||
// Merge combines multiple PDFs into a single PDF. The resulting page order
|
||||
// is determined by the order of files provided in inputPaths.
|
||||
Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||
|
||||
// Split splits a given PDF file.
|
||||
Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||
|
||||
// Flatten merges existing annotation appearances with page content,
|
||||
// effectively deleting the original annotations. This process can flatten
|
||||
// forms as well, as forms share a relationship with annotations. Note that
|
||||
// this operation is irreversible.
|
||||
Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error
|
||||
|
||||
// Convert transforms a given PDF to the specified formats defined in
|
||||
// PdfFormats. If no format, it does nothing.
|
||||
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
|
||||
// ReadMetadata extracts the metadata of a given PDF file.
|
||||
ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
|
||||
|
||||
// WriteMetadata writes the metadata into a given PDF file.
|
||||
WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
|
||||
}
|
||||
|
||||
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
||||
|
||||
8
pkg/gotenberg/shutdown.go
Normal file
8
pkg/gotenberg/shutdown.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package gotenberg
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrCancelGracefulShutdownContext tells that a module wants to abort a
|
||||
// graceful shutdown and stops Gotenberg right away as there are no more
|
||||
// ongoing processes.
|
||||
var ErrCancelGracefulShutdownContext = errors.New("cancel graceful shutdown's context")
|
||||
98
pkg/gotenberg/sort.go
Normal file
98
pkg/gotenberg/sort.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// AlphanumericSort implements sort.Interface and helps to sort strings
|
||||
// alphanumerically by either a numeric prefix or, if missing, a numeric
|
||||
// suffix.
|
||||
//
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/805.
|
||||
type AlphanumericSort []string
|
||||
|
||||
func (s AlphanumericSort) Len() int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func (s AlphanumericSort) Swap(i, j int) {
|
||||
s[i], s[j] = s[j], s[i]
|
||||
}
|
||||
|
||||
func (s AlphanumericSort) Less(i, j int) bool {
|
||||
numI, restI := extractNumber(s[i])
|
||||
numJ, restJ := extractNumber(s[j])
|
||||
|
||||
// If both strings contain a number, compare them numerically.
|
||||
if numI != -1 && numJ != -1 {
|
||||
if numI != numJ {
|
||||
return numI < numJ
|
||||
}
|
||||
// If the numbers are equal, compare the "rest" strings.
|
||||
return restI < restJ
|
||||
}
|
||||
|
||||
// If one contains a number and the other doesn't, the one with the number
|
||||
// comes first.
|
||||
if numI != -1 {
|
||||
return true
|
||||
}
|
||||
if numJ != -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Neither has a number; fall back to lexicographical order.
|
||||
return s[i] < s[j]
|
||||
}
|
||||
|
||||
// extractNumber attempts to extract a numeric portion from the filename.
|
||||
// It first checks for a numeric prefix (digits at the beginning).
|
||||
// If none is found, it next attempts to match a number immediately before the
|
||||
// extension (for filenames such as "sample1_1.pdf").
|
||||
// If that fails, it then attempts a trailing numeric pattern.
|
||||
// If no number is found, it returns -1 and the original string.
|
||||
func extractNumber(str string) (int, string) {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1168.
|
||||
str = filepath.Base(str)
|
||||
|
||||
// Check for a numeric prefix.
|
||||
if matches := prefixRegexp.FindStringSubmatch(str); len(matches) > 2 {
|
||||
if num, err := strconv.Atoi(matches[1]); err == nil {
|
||||
return num, matches[2]
|
||||
}
|
||||
}
|
||||
|
||||
// Check for a number immediately before an extension.
|
||||
if matches := extensionSuffixRegexp.FindStringSubmatch(str); len(matches) > 3 {
|
||||
if num, err := strconv.Atoi(matches[2]); err == nil {
|
||||
// Remove the numeric block but keep the extension.
|
||||
return num, matches[1] + matches[3]
|
||||
}
|
||||
}
|
||||
|
||||
// Check for a trailing number (with no extension following).
|
||||
if matches := suffixRegexp.FindStringSubmatch(str); len(matches) > 2 {
|
||||
if num, err := strconv.Atoi(matches[2]); err == nil {
|
||||
return num, matches[1]
|
||||
}
|
||||
}
|
||||
|
||||
// No numeric portion found.
|
||||
return -1, str
|
||||
}
|
||||
|
||||
// Regular expressions used by extractNumber.
|
||||
var (
|
||||
// Matches a numeric prefix: one or more digits at the start.
|
||||
prefixRegexp = regexp.MustCompile(`^(\d+)(.*)$`)
|
||||
// Matches a numeric block immediately before a file extension.
|
||||
extensionSuffixRegexp = regexp.MustCompile(`^(.*?)(\d+)(\.[^.]+)$`)
|
||||
// Matches a trailing numeric sequence when there is no extension.
|
||||
suffixRegexp = regexp.MustCompile(`^(.*?)(\d+)$`)
|
||||
)
|
||||
|
||||
// Interface guard.
|
||||
var _ sort.Interface = (*AlphanumericSort)(nil)
|
||||
44
pkg/gotenberg/sort_test.go
Normal file
44
pkg/gotenberg/sort_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlphanumericSort(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
values []string
|
||||
expectedSort []string
|
||||
}{
|
||||
{
|
||||
scenario: "numeric and letters",
|
||||
values: []string{"10qux.pdf", "2_baz.txt", "2_aza.txt", "1bar.pdf", "Afoo.txt", "Bbar.docx", "25zeta.txt", "3.pdf", "4_foo.pdf"},
|
||||
expectedSort: []string{"1bar.pdf", "2_aza.txt", "2_baz.txt", "3.pdf", "4_foo.pdf", "10qux.pdf", "25zeta.txt", "Afoo.txt", "Bbar.docx"},
|
||||
},
|
||||
{
|
||||
scenario: "numeric suffixes with extensions",
|
||||
values: []string{"sample1_10.pdf", "sample1_11.pdf", "sample1_4.pdf", "sample1_3.pdf", "sample1_1.pdf", "sample1_2.pdf"},
|
||||
expectedSort: []string{"sample1_1.pdf", "sample1_2.pdf", "sample1_3.pdf", "sample1_4.pdf", "sample1_10.pdf", "sample1_11.pdf"},
|
||||
},
|
||||
{
|
||||
scenario: "numeric suffixes",
|
||||
values: []string{"sample1_10", "sample1_11", "sample1_4", "sample1_3", "sample1_1", "sample1_2"},
|
||||
expectedSort: []string{"sample1_1", "sample1_2", "sample1_3", "sample1_4", "sample1_10", "sample1_11"},
|
||||
},
|
||||
{
|
||||
scenario: "hrtime (PHP library)",
|
||||
values: []string{"245654773395259", "245654773395039", "245654773395149", "245654773394919", "245654773394369"},
|
||||
expectedSort: []string{"245654773394369", "245654773394919", "245654773395039", "245654773395149", "245654773395259"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
sort.Sort(AlphanumericSort(tc.values))
|
||||
|
||||
if !reflect.DeepEqual(tc.values, tc.expectedSort) {
|
||||
t.Fatalf("expected %+v but got: %+v", tc.expectedSort, tc.values)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ import (
|
||||
// to restart an already restarting [Process].
|
||||
var ErrProcessAlreadyRestarting = errors.New("process already restarting")
|
||||
|
||||
// ErrMaximumQueueSizeExceeded happens if Run() is called but the maximum queue
|
||||
// size is already used.
|
||||
var ErrMaximumQueueSizeExceeded = errors.New("maximum queue size exceeded")
|
||||
|
||||
// Process is an interface that represents an abstract process
|
||||
// and provides methods for starting, stopping, and checking the health of the
|
||||
// process.
|
||||
@@ -74,6 +78,7 @@ type processSupervisor struct {
|
||||
logger *zap.Logger
|
||||
process Process
|
||||
maxReqLimit int64
|
||||
maxQueueSize int64
|
||||
mutexChan chan struct{}
|
||||
firstStart atomic.Bool
|
||||
reqCounter atomic.Int64
|
||||
@@ -83,12 +88,13 @@ type processSupervisor struct {
|
||||
}
|
||||
|
||||
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
||||
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit int64) ProcessSupervisor {
|
||||
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize int64) ProcessSupervisor {
|
||||
b := &processSupervisor{
|
||||
logger: logger,
|
||||
process: process,
|
||||
mutexChan: make(chan struct{}, 1),
|
||||
maxReqLimit: maxReqLimit,
|
||||
logger: logger,
|
||||
process: process,
|
||||
mutexChan: make(chan struct{}, 1),
|
||||
maxReqLimit: maxReqLimit,
|
||||
maxQueueSize: maxQueueSize,
|
||||
}
|
||||
b.reqCounter.Store(0)
|
||||
b.reqQueueSize.Store(0)
|
||||
@@ -167,6 +173,25 @@ func (s *processSupervisor) Healthy() bool {
|
||||
}
|
||||
|
||||
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
// A user reported a potential issue:
|
||||
//
|
||||
// "Although the counting operation is atomic, nothing prevent 2 concurrent
|
||||
// goroutines to retrieve the same 'currentQueueSize' and to compare its
|
||||
// value against the max limit. Then, resulting queue size would be 1 above
|
||||
// the allowed limit."
|
||||
//
|
||||
// However, he was unable to actually trigger this issue, even when sending
|
||||
// a lot of requests.
|
||||
//
|
||||
// For now, the best option is to consider this issue to be unlikely to
|
||||
// happen, and keep the code as it is because it is more readable this way.
|
||||
//
|
||||
// See https://github.com/gotenberg/gotenberg/issues/951.
|
||||
currentQueueSize := s.reqQueueSize.Load()
|
||||
if s.maxQueueSize > 0 && currentQueueSize >= s.maxQueueSize {
|
||||
return ErrMaximumQueueSizeExceeded
|
||||
}
|
||||
|
||||
s.reqQueueSize.Add(1)
|
||||
|
||||
for {
|
||||
@@ -176,10 +201,13 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
|
||||
logger.Debug("process lock acquired")
|
||||
s.reqQueueSize.Add(-1)
|
||||
s.reqCounter.Add(1)
|
||||
releaseMutexChan := true
|
||||
|
||||
defer func() {
|
||||
logger.Debug("process lock released")
|
||||
<-s.mutexChan
|
||||
if releaseMutexChan {
|
||||
logger.Debug("process lock released")
|
||||
<-s.mutexChan
|
||||
}
|
||||
}()
|
||||
|
||||
if !s.firstStart.Load() {
|
||||
@@ -201,18 +229,26 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
|
||||
}
|
||||
}
|
||||
|
||||
err := s.runWithDeadline(ctx, task)
|
||||
|
||||
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
|
||||
s.logger.Debug("max request limit reached, restarting...")
|
||||
err := s.runWithDeadline(ctx, func() error {
|
||||
return s.restart()
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("process restart before task: %w", err)
|
||||
}
|
||||
s.logger.Debug("max request limit reached, restarting eagerly...")
|
||||
releaseMutexChan = false
|
||||
|
||||
go func() {
|
||||
err := s.runWithDeadline(context.Background(), func() error {
|
||||
return s.restart()
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error(fmt.Sprintf("process restart after task: %v", err))
|
||||
}
|
||||
logger.Debug("process lock released")
|
||||
<-s.mutexChan
|
||||
}()
|
||||
}
|
||||
|
||||
// Note: no error wrapping because it leaks on Chromium console exceptions output.
|
||||
return s.runWithDeadline(ctx, task)
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
logger.Debug("failed to acquire process lock before deadline")
|
||||
s.reqQueueSize.Add(-1)
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
||||
if tc.firstStartSet {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5)
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0)
|
||||
err := ps.Shutdown()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
@@ -154,7 +154,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
||||
if tc.initiallyRestarting {
|
||||
ps.isRestarting.Store(true)
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
||||
if tc.initiallyStarted {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
@@ -249,6 +249,8 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
expectedStartCalls int64
|
||||
expectedHealthyCalls int64
|
||||
expectedStopCalls int64
|
||||
currentQueueSize int64
|
||||
maxQueueSize int64
|
||||
}{
|
||||
{
|
||||
scenario: "successfully run task on non-started process",
|
||||
@@ -323,7 +325,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
expectedStopCalls: 1,
|
||||
},
|
||||
{
|
||||
scenario: "cannot restart after reaching max request limit",
|
||||
scenario: "auto-restart after reaching max request limit",
|
||||
startError: errors.New("start error"),
|
||||
initiallyStarted: true,
|
||||
isRestarting: false,
|
||||
@@ -348,6 +350,34 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
expectedHealthyCalls: 1,
|
||||
expectedStopCalls: 0,
|
||||
},
|
||||
{
|
||||
scenario: "queue size exceeded",
|
||||
initiallyStarted: false,
|
||||
isRestarting: false,
|
||||
processHealthy: true,
|
||||
maxReqLimit: 2,
|
||||
tasksToRun: 1,
|
||||
expectError: true,
|
||||
expectedStartCalls: 0,
|
||||
expectedHealthyCalls: 0,
|
||||
expectedStopCalls: 0,
|
||||
currentQueueSize: 1,
|
||||
maxQueueSize: 1,
|
||||
},
|
||||
{
|
||||
scenario: "queue size not exceeded",
|
||||
initiallyStarted: false,
|
||||
isRestarting: false,
|
||||
processHealthy: true,
|
||||
maxReqLimit: 2,
|
||||
tasksToRun: 1,
|
||||
expectError: true,
|
||||
expectedStartCalls: 1,
|
||||
expectedHealthyCalls: 1,
|
||||
expectedStopCalls: 0,
|
||||
currentQueueSize: 1,
|
||||
maxQueueSize: 2,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
@@ -372,13 +402,16 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize).(*processSupervisor)
|
||||
if tc.initiallyStarted {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
if tc.isRestarting {
|
||||
ps.isRestarting.Store(true)
|
||||
}
|
||||
if tc.currentQueueSize > 0 {
|
||||
ps.reqQueueSize.Store(tc.currentQueueSize)
|
||||
}
|
||||
|
||||
task := func() error {
|
||||
return tc.taskError
|
||||
@@ -418,6 +451,10 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
// Making sure restarts are finished.
|
||||
ps.mutexChan <- struct{}{}
|
||||
<-ps.mutexChan
|
||||
|
||||
if startCalls.Load() != tc.expectedStartCalls {
|
||||
t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load())
|
||||
}
|
||||
@@ -451,13 +488,13 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0).(*processSupervisor)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if tc.ctxDone {
|
||||
cancel()
|
||||
} else {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
err := ps.runWithDeadline(ctx, func() error {
|
||||
@@ -485,7 +522,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
||||
return true
|
||||
},
|
||||
}
|
||||
ps := NewProcessSupervisor(logger, process, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
|
||||
|
||||
// Simulating a lock.
|
||||
ps.mutexChan <- struct{}{}
|
||||
@@ -586,7 +623,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
|
||||
ps.restartsCounter.Store(tc.initialRestartsCount)
|
||||
|
||||
for i := 0; i < tc.restartAttempts; i++ {
|
||||
|
||||
4
pkg/gotenberg/version.go
Normal file
4
pkg/gotenberg/version.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package gotenberg
|
||||
|
||||
// Version is the... version of the Gotenberg application.
|
||||
var Version = "snapshot"
|
||||
@@ -4,14 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/labstack/echo/v4"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/multierr"
|
||||
@@ -26,26 +26,42 @@ func init() {
|
||||
gotenberg.MustRegisterModule(new(Api))
|
||||
}
|
||||
|
||||
// Api is a module which provides an HTTP server. Other modules may add routes,
|
||||
// Api is a module that provides an HTTP server. Other modules may add routes,
|
||||
// middlewares or health checks.
|
||||
type Api struct {
|
||||
port int
|
||||
bindIp string
|
||||
tlsCertFile string
|
||||
tlsKeyFile string
|
||||
startTimeout time.Duration
|
||||
bodyLimit int64
|
||||
timeout time.Duration
|
||||
rootPath string
|
||||
traceHeader string
|
||||
basicAuthUsername string
|
||||
basicAuthPassword string
|
||||
downloadFromCfg downloadFromConfig
|
||||
disableHealthCheckLogging bool
|
||||
enableDebugRoute bool
|
||||
|
||||
routes []Route
|
||||
externalMiddlewares []Middleware
|
||||
healthChecks []health.CheckerOption
|
||||
readyFn []func() error
|
||||
asyncCounters []AsynchronousCounter
|
||||
fs *gotenberg.FileSystem
|
||||
logger *zap.Logger
|
||||
srv *echo.Echo
|
||||
}
|
||||
|
||||
// Router is a module interface which adds routes to the [Api].
|
||||
type downloadFromConfig struct {
|
||||
allowList *regexp2.Regexp
|
||||
denyList *regexp2.Regexp
|
||||
maxRetry int
|
||||
disable bool
|
||||
}
|
||||
|
||||
// Router is a module interface that adds routes to the [Api].
|
||||
type Router interface {
|
||||
Routes() ([]Route, error)
|
||||
}
|
||||
@@ -68,17 +84,17 @@ type Route struct {
|
||||
// Optional.
|
||||
DisableLogging bool
|
||||
|
||||
// Handler is the function which handles the request.
|
||||
// Handler is the function that handles the request.
|
||||
// Required.
|
||||
Handler echo.HandlerFunc
|
||||
}
|
||||
|
||||
// MiddlewareProvider is a module interface which adds middlewares to the [Api].
|
||||
// MiddlewareProvider is a module interface that adds middlewares to the [Api].
|
||||
type MiddlewareProvider interface {
|
||||
Middlewares() ([]Middleware, error)
|
||||
}
|
||||
|
||||
// MiddlewareStack is a type which helps to determine in which stack the
|
||||
// MiddlewareStack is a type that helps to determine in which stack the
|
||||
// middlewares provided by the [MiddlewareProvider] modules should be located.
|
||||
type MiddlewareStack uint32
|
||||
|
||||
@@ -88,7 +104,7 @@ const (
|
||||
MultipartStack
|
||||
)
|
||||
|
||||
// MiddlewarePriority is a type which helps to determine the execution order of
|
||||
// MiddlewarePriority is a type that helps to determine the execution order of
|
||||
// middlewares provided by the [MiddlewareProvider] modules in a stack.
|
||||
type MiddlewarePriority uint32
|
||||
|
||||
@@ -100,7 +116,7 @@ const (
|
||||
VeryHighPriority
|
||||
)
|
||||
|
||||
// Middleware is a middleware which can be added to the [Api]'s middlewares
|
||||
// Middleware is a middleware that can be added to the [Api]'s middlewares
|
||||
// chain.
|
||||
//
|
||||
// middleware := Middleware{
|
||||
@@ -142,7 +158,7 @@ type Middleware struct {
|
||||
Handler echo.MiddlewareFunc
|
||||
}
|
||||
|
||||
// HealthChecker is a module interface which allows adding health checks to the
|
||||
// HealthChecker is a module interface that allows adding health checks to the
|
||||
// API.
|
||||
//
|
||||
// See https://github.com/alexliesenfeld/health for more details.
|
||||
@@ -151,6 +167,14 @@ type HealthChecker interface {
|
||||
Ready() error
|
||||
}
|
||||
|
||||
// AsynchronousCounter is a module interface that returns the number of active
|
||||
// asynchronous requests.
|
||||
//
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1022.
|
||||
type AsynchronousCounter interface {
|
||||
AsyncCount() int64
|
||||
}
|
||||
|
||||
// Descriptor returns an [Api]'s module descriptor.
|
||||
func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
@@ -159,12 +183,21 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs := flag.NewFlagSet("api", flag.ExitOnError)
|
||||
fs.Int("api-port", 3000, "Set the port on which the API should listen")
|
||||
fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port")
|
||||
fs.String("api-bind-ip", "", "Set the IP address the API should bind to for incoming connections")
|
||||
fs.String("api-tls-cert-file", "", "Path to the TLS/SSL certificate file - for HTTPS support")
|
||||
fs.String("api-tls-key-file", "", "Path to the TLS/SSL key file - for HTTPS support")
|
||||
fs.Duration("api-start-timeout", time.Duration(30)*time.Second, "Set the time limit for the API to start")
|
||||
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
|
||||
fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc")
|
||||
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
|
||||
fs.String("api-trace-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.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression")
|
||||
fs.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression")
|
||||
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
|
||||
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
|
||||
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
|
||||
|
||||
fs.Bool("api-enable-debug-route", false, "Enable the debug route")
|
||||
return fs
|
||||
}(),
|
||||
New: func() gotenberg.Module { return new(Api) },
|
||||
@@ -175,33 +208,48 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
a.port = flags.MustInt("api-port")
|
||||
a.bindIp = flags.MustString("api-bind-ip")
|
||||
a.tlsCertFile = flags.MustString("api-tls-cert-file")
|
||||
a.tlsKeyFile = flags.MustString("api-tls-key-file")
|
||||
a.startTimeout = flags.MustDuration("api-start-timeout")
|
||||
a.timeout = flags.MustDuration("api-timeout")
|
||||
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
|
||||
a.rootPath = flags.MustString("api-root-path")
|
||||
a.traceHeader = flags.MustString("api-trace-header")
|
||||
a.downloadFromCfg = downloadFromConfig{
|
||||
allowList: flags.MustRegexp("api-download-from-allow-list"),
|
||||
denyList: flags.MustRegexp("api-download-from-deny-list"),
|
||||
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
||||
disable: flags.MustBool("api-disable-download-from"),
|
||||
}
|
||||
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
|
||||
a.enableDebugRoute = flags.MustBool("api-enable-debug-route")
|
||||
|
||||
// Port from env?
|
||||
portEnvVar := flags.MustString("api-port-from-env")
|
||||
if portEnvVar != "" {
|
||||
val, ok := os.LookupEnv(portEnvVar)
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("environment variable '%s' does not exist", portEnvVar)
|
||||
}
|
||||
|
||||
if val == "" {
|
||||
return fmt.Errorf("environment variable '%s' is empty", portEnvVar)
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(val)
|
||||
port, err := gotenberg.IntEnv(portEnvVar)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get int value of environment variable '%s': %w", portEnvVar, err)
|
||||
return fmt.Errorf("get API port from env: %w", err)
|
||||
}
|
||||
|
||||
a.port = port
|
||||
}
|
||||
|
||||
// Enable basic auth?
|
||||
enableBasicAuth := flags.MustBool("api-enable-basic-auth")
|
||||
if enableBasicAuth {
|
||||
basicAuthUsername, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_USERNAME")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get basic auth username from env: %w", err)
|
||||
}
|
||||
basicAuthPassword, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_PASSWORD")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get basic auth password from env: %w", err)
|
||||
}
|
||||
a.basicAuthUsername = basicAuthUsername
|
||||
a.basicAuthPassword = basicAuthPassword
|
||||
}
|
||||
|
||||
// Get routes from modules.
|
||||
mods, err := ctx.Modules(new(Router))
|
||||
if err != nil {
|
||||
@@ -268,6 +316,17 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
a.readyFn = append(a.readyFn, healthChecker.Ready)
|
||||
}
|
||||
|
||||
// Get asynchronous counters.
|
||||
mods, err = ctx.Modules(new(AsynchronousCounter))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get asynchronous counters: %w", err)
|
||||
}
|
||||
|
||||
a.asyncCounters = make([]AsynchronousCounter, len(mods))
|
||||
for i, asyncCounter := range mods {
|
||||
a.asyncCounters[i] = asyncCounter.(AsynchronousCounter)
|
||||
}
|
||||
|
||||
// Logger.
|
||||
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
|
||||
if err != nil {
|
||||
@@ -282,7 +341,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
a.logger = logger
|
||||
|
||||
// File system.
|
||||
a.fs = gotenberg.NewFileSystem()
|
||||
a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -297,6 +356,16 @@ func (a *Api) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
if a.bindIp != "" && net.ParseIP(a.bindIp) == nil {
|
||||
err = multierr.Append(err, errors.New("IP must be a valid IP address"))
|
||||
}
|
||||
|
||||
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
||||
err = multierr.Append(err,
|
||||
errors.New("both TLS certificate and key files must be set"),
|
||||
)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(a.rootPath, "/") {
|
||||
err = multierr.Append(err,
|
||||
errors.New("root path must start with /"),
|
||||
@@ -319,8 +388,10 @@ func (a *Api) Validate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
routesMap := make(map[string]string, len(a.routes)+1)
|
||||
routesMap := make(map[string]string, len(a.routes)+3)
|
||||
routesMap["/health"] = "/health"
|
||||
routesMap["/version"] = "/version"
|
||||
routesMap["/debug"] = "/debug"
|
||||
|
||||
for _, route := range a.routes {
|
||||
if route.Path == "" {
|
||||
@@ -380,7 +451,7 @@ func (a *Api) Start() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the user wish to add logging entries related to the health
|
||||
// Check if the user wishes to add logging entries related to the health
|
||||
// check route.
|
||||
if a.disableHealthCheckLogging {
|
||||
disableLoggingForPaths = append(disableLoggingForPaths, "health")
|
||||
@@ -402,19 +473,32 @@ func (a *Api) Start() error {
|
||||
a.srv.Pre(externalMiddleware.Handler)
|
||||
case MultipartStack:
|
||||
externalMultipartMiddlewares = append(externalMultipartMiddlewares, externalMiddleware)
|
||||
default:
|
||||
case DefaultStack:
|
||||
a.srv.Use(externalMiddleware.Handler)
|
||||
}
|
||||
}
|
||||
|
||||
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
|
||||
|
||||
// Basic auth?
|
||||
var securityMiddleware echo.MiddlewareFunc
|
||||
if a.basicAuthUsername != "" {
|
||||
securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword)
|
||||
} else {
|
||||
securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the modules' routes and their specific middlewares.
|
||||
for _, route := range a.routes {
|
||||
var middlewares []echo.MiddlewareFunc
|
||||
middlewares = append(middlewares, securityMiddleware)
|
||||
|
||||
if route.IsMultipart {
|
||||
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout))
|
||||
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg))
|
||||
|
||||
for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
|
||||
middlewares = append(middlewares, externalMultipartMiddleware.Handler)
|
||||
@@ -431,16 +515,63 @@ func (a *Api) Start() error {
|
||||
)
|
||||
}
|
||||
|
||||
// Let's not forget the health check route.
|
||||
// Root route.
|
||||
a.srv.GET(
|
||||
a.rootPath,
|
||||
func(c echo.Context) error {
|
||||
return c.HTML(http.StatusOK, `Hey, Gotenberg has no UI, it's an API. Head to the <a href="https://gotenberg.dev">documentation</a> to learn how to interact with it 🚀`)
|
||||
},
|
||||
securityMiddleware,
|
||||
)
|
||||
|
||||
// Favicon route.
|
||||
a.srv.GET(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "favicon.ico"),
|
||||
func(c echo.Context) error {
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
},
|
||||
securityMiddleware,
|
||||
)
|
||||
|
||||
// Let's not forget the health check routes...
|
||||
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
|
||||
checker := health.NewChecker(checks...)
|
||||
healthCheckHandler := health.NewHandler(checker)
|
||||
|
||||
a.srv.GET(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "health"),
|
||||
func() echo.HandlerFunc {
|
||||
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
|
||||
checker := health.NewChecker(checks...)
|
||||
return echo.WrapHandler(health.NewHandler(checker))
|
||||
return echo.WrapHandler(healthCheckHandler)
|
||||
}(),
|
||||
hardTimeoutMiddleware(hardTimeout),
|
||||
)
|
||||
a.srv.HEAD(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "health"),
|
||||
func() echo.HandlerFunc {
|
||||
return echo.WrapHandler(healthCheckHandler)
|
||||
}(),
|
||||
hardTimeoutMiddleware(hardTimeout),
|
||||
)
|
||||
|
||||
// ...the version route.
|
||||
a.srv.GET(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "version"),
|
||||
func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, gotenberg.Version)
|
||||
},
|
||||
securityMiddleware,
|
||||
)
|
||||
|
||||
// ...and the debug route.
|
||||
if a.enableDebugRoute {
|
||||
a.srv.GET(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "debug"),
|
||||
func(c echo.Context) error {
|
||||
return c.JSONPretty(http.StatusOK, gotenberg.Debug(), " ")
|
||||
},
|
||||
securityMiddleware,
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for all modules to be ready.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), a.startTimeout)
|
||||
@@ -458,8 +589,15 @@ func (a *Api) Start() error {
|
||||
|
||||
// As the following code is blocking, run it in a goroutine.
|
||||
go func() {
|
||||
server := &http2.Server{}
|
||||
err := a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server)
|
||||
var err error
|
||||
if a.tlsCertFile != "" && a.tlsKeyFile != "" {
|
||||
// Start an HTTPS server (supports HTTP/2).
|
||||
err = a.srv.StartTLS(fmt.Sprintf("%s:%d", a.bindIp, a.port), a.tlsCertFile, a.tlsKeyFile)
|
||||
} else {
|
||||
// Start an HTTP/2 Cleartext (non-HTTPS) server.
|
||||
server := &http2.Server{}
|
||||
err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server)
|
||||
}
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
a.logger.Fatal(err.Error())
|
||||
}
|
||||
@@ -470,12 +608,37 @@ func (a *Api) Start() error {
|
||||
|
||||
// StartupMessage returns a custom startup message.
|
||||
func (a *Api) StartupMessage() string {
|
||||
return fmt.Sprintf("server listening on port %d", a.port)
|
||||
ip := a.bindIp
|
||||
if a.bindIp == "" {
|
||||
ip = "[::]"
|
||||
}
|
||||
return fmt.Sprintf("server started on %s:%d", ip, a.port)
|
||||
}
|
||||
|
||||
// Stop stops the HTTP server.
|
||||
func (a *Api) Stop(ctx context.Context) error {
|
||||
return a.srv.Shutdown(ctx)
|
||||
for {
|
||||
count := int64(0)
|
||||
for _, asyncCounter := range a.asyncCounters {
|
||||
count += asyncCounter.AsyncCount()
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return a.srv.Shutdown(ctx)
|
||||
default:
|
||||
a.logger.Debug(fmt.Sprintf("%d asynchronous requests", count))
|
||||
if count > 0 {
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
a.logger.Debug("no more asynchronous requests, continue with shutdown")
|
||||
err := a.srv.Shutdown(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shutdown: %w", err)
|
||||
}
|
||||
return gotenberg.ErrCancelGracefulShutdownContext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
|
||||
@@ -1,855 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestApi_Descriptor(t *testing.T) {
|
||||
descriptor := new(Api).Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(Api))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Provision(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *gotenberg.Context
|
||||
setEnv func()
|
||||
expectPort int
|
||||
expectMiddlewares []Middleware
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "port from env: non-existing environment variable",
|
||||
ctx: func() *gotenberg.Context {
|
||||
fs := new(Api).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-port-from-env=FOO"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: fs,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "port from env: empty environment variable",
|
||||
ctx: func() *gotenberg.Context {
|
||||
fs := new(Api).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-port-from-env=PORT"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: fs,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}(),
|
||||
setEnv: func() {
|
||||
err := os.Setenv("PORT", "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "port from env: invalid environment variable value",
|
||||
ctx: func() *gotenberg.Context {
|
||||
fs := new(Api).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-port-from-env=PORT"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: fs,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}(),
|
||||
setEnv: func() {
|
||||
err := os.Setenv("PORT", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no valid routers",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.ValidatorMock
|
||||
RouterMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.ValidateMock = func() error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
mod.RoutesMock = func() ([]Route, error) {
|
||||
return nil, nil
|
||||
}
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "cannot retrieve routes from router",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
RouterMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.RoutesMock = func() ([]Route, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no valid middleware providers",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.ValidatorMock
|
||||
MiddlewareProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.ValidateMock = func() error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
mod.MiddlewaresMock = func() ([]Middleware, error) {
|
||||
return nil, nil
|
||||
}
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "cannot retrieve middlewares from middleware provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
MiddlewareProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.MiddlewaresMock = func() ([]Middleware, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no valid health checkers",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.ValidatorMock
|
||||
HealthCheckerMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.ValidateMock = func() error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "cannot retrieve health checks from health checker",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
HealthCheckerMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.ChecksMock = func() ([]health.CheckerOption, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no logger from logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod1 := &struct {
|
||||
gotenberg.ModuleMock
|
||||
RouterMock
|
||||
}{}
|
||||
mod1.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }}
|
||||
}
|
||||
mod1.RoutesMock = func() ([]Route, error) {
|
||||
return []Route{{}}, nil
|
||||
}
|
||||
|
||||
mod2 := &struct {
|
||||
gotenberg.ModuleMock
|
||||
MiddlewareProviderMock
|
||||
}{}
|
||||
mod2.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }}
|
||||
}
|
||||
mod2.MiddlewaresMock = func() ([]Middleware, error) {
|
||||
return []Middleware{
|
||||
{
|
||||
Priority: VeryLowPriority,
|
||||
},
|
||||
{
|
||||
Priority: LowPriority,
|
||||
},
|
||||
{
|
||||
Priority: MediumPriority,
|
||||
},
|
||||
{
|
||||
Priority: HighPriority,
|
||||
},
|
||||
{
|
||||
Priority: VeryHighPriority,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
mod3 := &struct {
|
||||
gotenberg.ModuleMock
|
||||
HealthCheckerMock
|
||||
}{}
|
||||
mod3.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return mod3 }}
|
||||
}
|
||||
mod3.ChecksMock = func() ([]health.CheckerOption, error) {
|
||||
return []health.CheckerOption{health.WithDisabledAutostart()}, nil
|
||||
}
|
||||
mod3.ReadyMock = func() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mod4 := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
mod4.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "qux", New: func() gotenberg.Module { return mod4 }}
|
||||
}
|
||||
mod4.LoggerMock = func(_ gotenberg.Module) (*zap.Logger, error) {
|
||||
return zap.NewNop(), nil
|
||||
}
|
||||
|
||||
fs := new(Api).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-port-from-env=PORT"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: fs,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod1.Descriptor(),
|
||||
mod2.Descriptor(),
|
||||
mod3.Descriptor(),
|
||||
mod4.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
setEnv: func() {
|
||||
err := os.Setenv("PORT", "1337")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectPort: 1337,
|
||||
expectMiddlewares: []Middleware{
|
||||
{
|
||||
Priority: VeryHighPriority,
|
||||
},
|
||||
{
|
||||
Priority: HighPriority,
|
||||
},
|
||||
{
|
||||
Priority: MediumPriority,
|
||||
},
|
||||
{
|
||||
Priority: LowPriority,
|
||||
},
|
||||
{
|
||||
Priority: VeryLowPriority,
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.setEnv != nil {
|
||||
tc.setEnv()
|
||||
}
|
||||
|
||||
mod := new(Api)
|
||||
err := mod.Provision(tc.ctx)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectPort != 0 && mod.port != tc.expectPort {
|
||||
t.Errorf("expected port %d but got %d", tc.expectPort, mod.port)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(mod.externalMiddlewares, tc.expectMiddlewares) {
|
||||
t.Errorf("expected %+v, but got: %+v", tc.expectMiddlewares, mod.externalMiddlewares)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Validate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
port int
|
||||
rootPath string
|
||||
traceHeader string
|
||||
routes []Route
|
||||
middlewares []Middleware
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "invalid port (< 1)",
|
||||
port: 0,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: nil,
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid port (> 65535)",
|
||||
port: 65536,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: nil,
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid root path: missing / prefix",
|
||||
port: 10,
|
||||
rootPath: "foo/",
|
||||
traceHeader: "foo",
|
||||
routes: nil,
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid root path: missing / suffix",
|
||||
port: 10,
|
||||
rootPath: "/foo",
|
||||
traceHeader: "foo",
|
||||
routes: nil,
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid trace header",
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "",
|
||||
routes: nil,
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid route: empty path",
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Path: "",
|
||||
},
|
||||
},
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid route: missing / prefix in path",
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Path: "foo",
|
||||
},
|
||||
},
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid multipart route: no /forms prefix in path",
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Path: "/foo",
|
||||
IsMultipart: true,
|
||||
},
|
||||
},
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid route: no method",
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Path: "/foo",
|
||||
Method: "",
|
||||
},
|
||||
},
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid route: nil handler",
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/foo",
|
||||
Handler: nil,
|
||||
},
|
||||
},
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid route: path already existing",
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/foo",
|
||||
Handler: func(_ echo.Context) error { return nil },
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/foo",
|
||||
Handler: func(_ echo.Context) error { return nil },
|
||||
},
|
||||
},
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid middleware: nil handler",
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: nil,
|
||||
middlewares: []Middleware{
|
||||
{
|
||||
Priority: HighPriority,
|
||||
Handler: nil,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/foo",
|
||||
Handler: func(_ echo.Context) error { return nil },
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/forms/foo",
|
||||
Handler: func(_ echo.Context) error { return nil },
|
||||
IsMultipart: true,
|
||||
},
|
||||
},
|
||||
middlewares: []Middleware{
|
||||
{
|
||||
Priority: HighPriority,
|
||||
Handler: func() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := Api{
|
||||
port: tc.port,
|
||||
rootPath: tc.rootPath,
|
||||
traceHeader: tc.traceHeader,
|
||||
routes: tc.routes,
|
||||
externalMiddlewares: tc.middlewares,
|
||||
}
|
||||
|
||||
err := mod.Validate()
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Start(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
readyFn []func() error
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "at least one module not ready",
|
||||
readyFn: []func() error{
|
||||
func() error { return nil },
|
||||
func() error { return errors.New("not ready") },
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
readyFn: []func() error{
|
||||
func() error { return nil },
|
||||
func() error { return nil },
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(Api)
|
||||
mod.port = 3000
|
||||
mod.startTimeout = time.Duration(30) * time.Second
|
||||
mod.rootPath = "/"
|
||||
mod.disableHealthCheckLogging = true
|
||||
mod.routes = []Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/foo",
|
||||
IsMultipart: true,
|
||||
DisableLogging: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*Context)
|
||||
ctx.outputPaths = []string{
|
||||
"/tests/test/testdata/api/sample1.txt",
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/bar",
|
||||
IsMultipart: true,
|
||||
Handler: func(_ echo.Context) error { return errors.New("foo") },
|
||||
},
|
||||
}
|
||||
mod.externalMiddlewares = []Middleware{
|
||||
{
|
||||
Stack: PreRouterStack,
|
||||
Handler: func() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}(),
|
||||
},
|
||||
{
|
||||
Stack: MultipartStack,
|
||||
Handler: func() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}(),
|
||||
},
|
||||
{
|
||||
Stack: DefaultStack,
|
||||
Handler: func() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}(),
|
||||
},
|
||||
{
|
||||
Handler: func() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}(),
|
||||
},
|
||||
}
|
||||
mod.readyFn = tc.readyFn
|
||||
mod.fs = gotenberg.NewFileSystem()
|
||||
mod.logger = zap.NewNop()
|
||||
|
||||
err := mod.Start()
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectError {
|
||||
return
|
||||
}
|
||||
|
||||
// health request.
|
||||
recorder := httptest.NewRecorder()
|
||||
healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
|
||||
mod.srv.ServeHTTP(recorder, healthRequest)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
// "multipart/form-data" request.
|
||||
multipartRequest := func(url string) *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err := writer.WriteField("foo", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
_, err = part.Write([]byte("foo"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, url, body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/foo"))
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/bar"))
|
||||
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected %d status code but got %d", http.StatusInternalServerError, recorder.Code)
|
||||
}
|
||||
|
||||
err = mod.srv.Shutdown(context.TODO())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_StartupMessage(t *testing.T) {
|
||||
mod := Api{
|
||||
port: 3000,
|
||||
}
|
||||
|
||||
actual := mod.StartupMessage()
|
||||
expect := "server listening on port 3000"
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Stop(t *testing.T) {
|
||||
mod := &Api{
|
||||
port: 3000,
|
||||
routes: []Route{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/foo",
|
||||
Handler: func(_ echo.Context) error { return nil },
|
||||
},
|
||||
},
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
|
||||
err := mod.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
err = mod.Stop(context.TODO())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"compress/flate"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mholt/archiver/v3"
|
||||
"github.com/mholt/archives"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
@@ -34,27 +38,76 @@ var (
|
||||
|
||||
// Context is the request context for a "multipart/form-data" requests.
|
||||
type Context struct {
|
||||
dirPath string
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
|
||||
dirPath string
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
outputPaths []string
|
||||
cancelled bool
|
||||
|
||||
cancelled bool
|
||||
logger *zap.Logger
|
||||
echoCtx echo.Context
|
||||
logger *zap.Logger
|
||||
echoCtx echo.Context
|
||||
mkdirAll gotenberg.MkdirAll
|
||||
pathRename gotenberg.PathRename
|
||||
context.Context
|
||||
}
|
||||
|
||||
type trackingReader struct {
|
||||
R io.Reader
|
||||
AddReadBytes func(n int64) error
|
||||
}
|
||||
|
||||
func (t *trackingReader) Read(p []byte) (int, error) {
|
||||
n, err := t.R.Read(p)
|
||||
if n > 0 {
|
||||
errAddRead := t.AddReadBytes(int64(n))
|
||||
if errAddRead != nil {
|
||||
return n, fmt.Errorf("add read bytes: %w", errAddRead)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// It's a common practice in Go to return io.EOF unwrapped to signal
|
||||
// the end of a data stream. Wrapping it can lead to unexpected
|
||||
// behavior in standard library functions.
|
||||
return n, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type downloadFrom struct {
|
||||
// Url is the URL to download a file from.
|
||||
Url string `json:"url"`
|
||||
|
||||
// ExtraHttpHeaders are the HTTP headers to send alongside.
|
||||
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
|
||||
}
|
||||
|
||||
// newContext returns a [Context] by parsing a "multipart/form-data" request.
|
||||
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration) (*Context, context.CancelFunc, error) {
|
||||
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) {
|
||||
processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
|
||||
|
||||
// We want to make sure the multipart/form-data does not exceed a given
|
||||
// limit. We consider: form fields (keys, values, files) and files
|
||||
// downloaded remotely ("download from" feature).
|
||||
var totalBytesRead atomic.Int64
|
||||
|
||||
addReadBytes := func(n int64) error {
|
||||
newTotal := totalBytesRead.Add(n)
|
||||
if bodyLimit != 0 && newTotal > bodyLimit {
|
||||
return WrapError(
|
||||
fmt.Errorf("body limit reached (> %d)", bodyLimit),
|
||||
NewSentinelHttpError(http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := &Context{
|
||||
outputPaths: make([]string, 0),
|
||||
cancelled: false,
|
||||
logger: logger,
|
||||
echoCtx: echoCtx,
|
||||
mkdirAll: new(gotenberg.OsMkdirAll),
|
||||
pathRename: new(gotenberg.OsPathRename),
|
||||
Context: processCtx,
|
||||
}
|
||||
|
||||
@@ -86,7 +139,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
|
||||
form, err := echoCtx.MultipartForm()
|
||||
if err != nil {
|
||||
|
||||
if errors.Is(err, http.ErrNotMultipart) {
|
||||
return nil, cancel, WrapError(
|
||||
fmt.Errorf("get multipart form: %w", err),
|
||||
@@ -111,6 +163,19 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
|
||||
}
|
||||
|
||||
// This will ensure we do not exceed the body limit.
|
||||
var formValuesSize int64
|
||||
for key, valArray := range form.Value {
|
||||
formValuesSize += int64(len(key))
|
||||
for _, val := range valArray {
|
||||
formValuesSize += int64(len(val))
|
||||
}
|
||||
}
|
||||
err = addReadBytes(formValuesSize)
|
||||
if err != nil {
|
||||
return nil, cancel, fmt.Errorf("add read bytes: %w", err)
|
||||
}
|
||||
|
||||
dirPath, err := fs.MkdirAll()
|
||||
if err != nil {
|
||||
return nil, cancel, fmt.Errorf("create working directory: %w", err)
|
||||
@@ -120,6 +185,150 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
ctx.values = form.Value
|
||||
ctx.files = make(map[string]string)
|
||||
|
||||
// First, try to download files listed in the "downloadFrom" form field, if
|
||||
// any.
|
||||
raw, ok := ctx.values["downloadFrom"]
|
||||
if !downloadFromCfg.disable && ok {
|
||||
var dls []downloadFrom
|
||||
err = json.Unmarshal([]byte(raw[0]), &dls)
|
||||
if err != nil {
|
||||
return nil, cancel, WrapError(
|
||||
fmt.Errorf("unmarshal json: %w", err),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field value: %s", err)),
|
||||
)
|
||||
}
|
||||
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
for i, dl := range dls {
|
||||
eg.Go(func() error {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
// Should not happen, as context is created with a timeout.
|
||||
return errors.New("context has no deadline")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(dl.Url) == "" {
|
||||
return WrapError(
|
||||
errors.New("empty download from URL"),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field entry %d: URL must be set", i)),
|
||||
)
|
||||
}
|
||||
|
||||
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter URL: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("download file from '%s'", dl.Url))
|
||||
|
||||
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request to '%s': %w", dl.Url, err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Gotenberg")
|
||||
for key, value := range dl.ExtraHttpHeaders {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
req.Header.Set(traceHeader, trace)
|
||||
|
||||
client := &retryablehttp.Client{
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: time.Until(deadline),
|
||||
},
|
||||
RetryMax: downloadFromCfg.maxRetry,
|
||||
RetryWaitMin: time.Duration(1) * time.Second,
|
||||
RetryWaitMax: time.Until(deadline),
|
||||
Logger: gotenberg.NewLeveledLogger(logger),
|
||||
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
||||
Backoff: retryablehttp.DefaultBackoff,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return WrapError(
|
||||
fmt.Errorf("download file from to '%s': %w", dl.Url, err),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
|
||||
)
|
||||
}
|
||||
defer func() {
|
||||
err := resp.Body.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close response body from '%s': %s", dl.Url, err))
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return WrapError(
|
||||
fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': got status: '%s'", dl.Url, resp.Status)),
|
||||
)
|
||||
}
|
||||
|
||||
contentDisposition := resp.Header.Get("Content-Disposition")
|
||||
if contentDisposition == "" {
|
||||
return WrapError(
|
||||
fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("No 'Content-Disposition' header from '%s'", dl.Url)),
|
||||
)
|
||||
}
|
||||
|
||||
// FIXME: the implementation of this method might not be
|
||||
// complete, as it fails to parse an empty mediatype.
|
||||
// See: https://github.com/golang/go/issues/69551.
|
||||
_, params, err := mime.ParseMediaType(contentDisposition)
|
||||
if err != nil {
|
||||
return WrapError(
|
||||
fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': %s", contentDisposition, dl.Url, err)),
|
||||
)
|
||||
}
|
||||
|
||||
filename, ok := params["filename"]
|
||||
if !ok {
|
||||
return WrapError(
|
||||
fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': no filename", contentDisposition, dl.Url)),
|
||||
)
|
||||
}
|
||||
|
||||
// Avoid directory traversal and make sure filename characters are
|
||||
// normalized.
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
||||
filename = norm.NFC.String(filepath.Base(filename))
|
||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
|
||||
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create local file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close local file: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
// This will ensure we do not exceed the body limit.
|
||||
reader := &trackingReader{R: resp.Body, AddReadBytes: addReadBytes}
|
||||
|
||||
_, err = io.Copy(out, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
|
||||
}
|
||||
|
||||
ctx.files[filename] = path
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
err = eg.Wait()
|
||||
if err != nil {
|
||||
return ctx, cancel, err
|
||||
}
|
||||
}
|
||||
|
||||
copyToDisk := func(fh *multipart.FileHeader) error {
|
||||
in, err := fh.Open()
|
||||
if err != nil {
|
||||
@@ -133,6 +342,9 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
}
|
||||
}()
|
||||
|
||||
// This will ensure we do not exceed the body limit.
|
||||
reader := &trackingReader{R: in, AddReadBytes: addReadBytes}
|
||||
|
||||
// Avoid directory traversal and make sure filename characters are
|
||||
// normalized.
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
||||
@@ -143,7 +355,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
if err != nil {
|
||||
return fmt.Errorf("create local file: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
@@ -151,7 +362,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
_, err = io.Copy(out, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy multipart file to local file: %w", err)
|
||||
}
|
||||
@@ -161,10 +372,10 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
return nil
|
||||
}
|
||||
|
||||
// Then, copy the form files, if any.
|
||||
for _, files := range form.File {
|
||||
for _, fh := range files {
|
||||
err = copyToDisk(fh)
|
||||
|
||||
if err != nil {
|
||||
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
|
||||
}
|
||||
@@ -173,6 +384,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
|
||||
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
|
||||
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
|
||||
ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
|
||||
|
||||
return ctx, cancel, err
|
||||
}
|
||||
@@ -191,10 +403,39 @@ func (ctx *Context) FormData() *FormData {
|
||||
}
|
||||
}
|
||||
|
||||
// GeneratePath generates a path within the context's working directory. It
|
||||
// does not create a file.
|
||||
// GeneratePath generates a path within the context's working directory.
|
||||
// It generates a new UUID-based filename. It does not create a file.
|
||||
func (ctx *Context) GeneratePath(extension string) string {
|
||||
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New(), extension)
|
||||
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New().String(), extension)
|
||||
}
|
||||
|
||||
// GeneratePathFromFilename generates a path within the context's working
|
||||
// directory, using the given filename (with extension). It does not create
|
||||
// a file.
|
||||
func (ctx *Context) GeneratePathFromFilename(filename string) string {
|
||||
return fmt.Sprintf("%s/%s", ctx.dirPath, filename)
|
||||
}
|
||||
|
||||
// CreateSubDirectory creates a subdirectory within the context's working
|
||||
// directory.
|
||||
func (ctx *Context) CreateSubDirectory(dirName string) (string, error) {
|
||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, dirName)
|
||||
err := ctx.mkdirAll.MkdirAll(path, 0o755)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create sub-directory %s: %w", path, err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Rename is just a wrapper around [os.Rename], as we need to mock this
|
||||
// behavior in our tests.
|
||||
func (ctx *Context) Rename(oldpath, newpath string) error {
|
||||
ctx.Log().Debug(fmt.Sprintf("rename %s to %s", oldpath, newpath))
|
||||
err := ctx.pathRename.Rename(oldpath, newpath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename path: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddOutputPaths adds the given paths. Those paths will be used later to build
|
||||
@@ -233,22 +474,33 @@ func (ctx *Context) BuildOutputFile() (string, error) {
|
||||
|
||||
if len(ctx.outputPaths) == 1 {
|
||||
ctx.logger.Debug(fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0]))
|
||||
|
||||
return ctx.outputPaths[0], nil
|
||||
}
|
||||
|
||||
z := archiver.Zip{
|
||||
CompressionLevel: flate.DefaultCompression,
|
||||
MkdirAll: true,
|
||||
SelectiveCompression: true,
|
||||
ContinueOnError: false,
|
||||
OverwriteExisting: false,
|
||||
ImplicitTopLevelFolder: false,
|
||||
filesInfo, err := archives.FilesFromDisk(ctx.Context, nil, func() map[string]string {
|
||||
f := make(map[string]string)
|
||||
for _, outputPath := range ctx.outputPaths {
|
||||
f[outputPath] = ""
|
||||
}
|
||||
return f
|
||||
}())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create files info: %w", err)
|
||||
}
|
||||
|
||||
archivePath := ctx.GeneratePath(".zip")
|
||||
out, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create zip file: %w", err)
|
||||
}
|
||||
defer func(out *os.File) {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
ctx.logger.Error(fmt.Sprintf("close zip file: %s", err))
|
||||
}
|
||||
}(out)
|
||||
|
||||
err := z.Archive(ctx.outputPaths, archivePath)
|
||||
err = archives.Zip{}.Archive(ctx.Context, out, filesInfo)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("archive output files: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestNewContext(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
request *http.Request
|
||||
expectError bool
|
||||
expectHttpError bool
|
||||
expectHttpStatus int
|
||||
}{
|
||||
{
|
||||
scenario: "http.ErrNotMultipart",
|
||||
request: httptest.NewRequest(http.MethodPost, "/", nil),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusUnsupportedMediaType,
|
||||
},
|
||||
{
|
||||
scenario: "http.ErrMissingBoundary",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEMultipartForm)
|
||||
return req
|
||||
}(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusUnsupportedMediaType,
|
||||
},
|
||||
{
|
||||
scenario: "malformed body",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("foo", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("foo", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
_, err = part.Write([]byte("foo"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
handler := func(c echo.Context) error {
|
||||
_, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second)
|
||||
defer cancel()
|
||||
// Context already cancelled.
|
||||
defer cancel()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(tc.request, recorder)
|
||||
err := handler(c)
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none", err)
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
var httpErr HttpError
|
||||
isHttpError := errors.As(err, &httpErr)
|
||||
|
||||
if tc.expectHttpError && !isHttpError {
|
||||
t.Errorf("expected an HTTP error but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectHttpError && isHttpError {
|
||||
t.Errorf("expected no HTTP error but got one: %v", httpErr)
|
||||
}
|
||||
|
||||
if err != nil && tc.expectHttpError && isHttpError {
|
||||
status, _ := httpErr.HttpError()
|
||||
if status != tc.expectHttpStatus {
|
||||
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_Request(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
c := echo.New().NewContext(request, recorder)
|
||||
|
||||
ctx := &Context{
|
||||
echoCtx: c,
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(ctx.Request(), c.Request()) {
|
||||
t.Errorf("expected %v but got %v", ctx.Request(), c.Request())
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_FormData(t *testing.T) {
|
||||
ctx := &Context{
|
||||
values: map[string][]string{
|
||||
"foo": {"foo"},
|
||||
},
|
||||
files: map[string]string{
|
||||
"foo.txt": "/foo.txt",
|
||||
},
|
||||
}
|
||||
|
||||
actual := ctx.FormData()
|
||||
expect := &FormData{
|
||||
values: ctx.values,
|
||||
files: ctx.files,
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(actual, expect) {
|
||||
t.Errorf("expected %+v but got %+v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_GeneratePath(t *testing.T) {
|
||||
ctx := &Context{
|
||||
dirPath: "/foo",
|
||||
}
|
||||
|
||||
path := ctx.GeneratePath(".pdf")
|
||||
|
||||
if !strings.HasPrefix(path, ctx.dirPath) {
|
||||
t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_AddOutputPaths(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *Context
|
||||
path string
|
||||
expectCount int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "ErrContextAlreadyClosed",
|
||||
ctx: &Context{cancelled: true},
|
||||
expectCount: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "ErrOutOfBoundsOutputPath",
|
||||
ctx: &Context{dirPath: "/foo"},
|
||||
path: "/bar/foo.txt",
|
||||
expectCount: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
ctx: &Context{dirPath: "/foo"},
|
||||
path: "/foo/foo.txt",
|
||||
expectCount: 1,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.ctx.AddOutputPaths(tc.path)
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none", err)
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if len(tc.ctx.outputPaths) != tc.expectCount {
|
||||
t.Errorf("expected %d output paths but got %d", tc.expectCount, len(tc.ctx.outputPaths))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_Log(t *testing.T) {
|
||||
expect := zap.NewNop()
|
||||
ctx := Context{logger: expect}
|
||||
actual := ctx.Log()
|
||||
|
||||
if !reflect.DeepEqual(actual, expect) {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_BuildOutputFile(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "ErrContextAlreadyClosed",
|
||||
ctx: &Context{cancelled: true},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no output path",
|
||||
ctx: &Context{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success: one output path",
|
||||
ctx: &Context{outputPaths: []string{"foo.txt"}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "cannot archive: invalid output paths",
|
||||
ctx: &Context{outputPaths: []string{"foo.txt", "foo.pdf"}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success: many output paths",
|
||||
ctx: &Context{
|
||||
outputPaths: []string{
|
||||
"/tests/test/testdata/api/sample1.txt",
|
||||
"/tests/test/testdata/api/sample1.txt",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
dirPath, err := fs.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no erro but got: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := os.RemoveAll(fs.WorkingDirPath())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
tc.ctx.dirPath = dirPath
|
||||
tc.ctx.logger = zap.NewNop()
|
||||
|
||||
_, err = tc.ctx.BuildOutputFile()
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none", err)
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_OutputFilename(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *Context
|
||||
outputPath string
|
||||
expectOutputFilename string
|
||||
}{
|
||||
{
|
||||
scenario: "with Gotenberg-Output-Filename header",
|
||||
ctx: func() *Context {
|
||||
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
|
||||
c.Request().Header.Set("Gotenberg-Output-Filename", "foo")
|
||||
return &Context{echoCtx: c}
|
||||
}(),
|
||||
outputPath: "/foo/bar.txt",
|
||||
expectOutputFilename: "foo.txt",
|
||||
},
|
||||
{
|
||||
scenario: "without custom filename",
|
||||
ctx: func() *Context {
|
||||
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
|
||||
return &Context{echoCtx: c}
|
||||
}(),
|
||||
outputPath: "/foo/foo.txt",
|
||||
expectOutputFilename: "foo.txt",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
actual := tc.ctx.OutputFilename(tc.outputPath)
|
||||
|
||||
if actual != tc.expectOutputFilename {
|
||||
t.Errorf("expected '%s' but got '%s'", tc.expectOutputFilename, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
// Package api provides a module which is an HTTP server. Other modules may
|
||||
// Package api provides a module, which is an HTTP server. Other modules may
|
||||
// add multipart/form-data routes, middlewares, and health checks.
|
||||
package api
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// FormData is a helper for validating and hydrating values from a
|
||||
@@ -24,7 +27,7 @@ type FormData struct {
|
||||
}
|
||||
|
||||
// Validate returns nil or an error related to the [FormData] values, with a
|
||||
// [SentinelHttpError] (status code 400, errors' details as message) wrapped
|
||||
// [SentinelHttpError] (status code 400, errors' details as a message) wrapped
|
||||
// inside.
|
||||
//
|
||||
// var foo string
|
||||
@@ -93,7 +96,7 @@ func (form *FormData) Int(key string, target *int, defaultValue int) *FormData {
|
||||
}
|
||||
|
||||
// MandatoryInt binds a form field to an int variable. It populates an
|
||||
// error if the value is not int, is empty, or the "key" does not exist.
|
||||
// error if the value is not int, or is empty, or the "key" does not exist.
|
||||
//
|
||||
// var foo int
|
||||
//
|
||||
@@ -113,7 +116,7 @@ func (form *FormData) Float64(key string, target *float64, defaultValue float64)
|
||||
}
|
||||
|
||||
// MandatoryFloat64 binds a form field to a float64 variable. It populates
|
||||
// an error if the is not float64, is empty, or the "key" does not exist.
|
||||
// an error if the value is not float64, is empty, or the "key" does not exist.
|
||||
//
|
||||
// var foo float64
|
||||
//
|
||||
@@ -133,8 +136,8 @@ func (form *FormData) Duration(key string, target *time.Duration, defaultValue t
|
||||
}
|
||||
|
||||
// MandatoryDuration binds a form field to a time.Duration variable. It
|
||||
// populates an error if the value is not time.Duration, is empty, or the "key"
|
||||
// does not exist.
|
||||
// populates an error if the value is not time.Duration, or is empty, or the
|
||||
// "key" does not exist.
|
||||
//
|
||||
// var foo time.Duration
|
||||
//
|
||||
@@ -143,6 +146,91 @@ func (form *FormData) MandatoryDuration(key string, target *time.Duration) *Form
|
||||
return form.mustMandatoryField(key, target)
|
||||
}
|
||||
|
||||
// Inches bind a form field to a float64 variable. It populates an error
|
||||
// if the value cannot be computed back to inches.
|
||||
//
|
||||
// var foo float64
|
||||
//
|
||||
// ctx.FormData().Inches("foo", &foo, 2.0)
|
||||
func (form *FormData) Inches(key string, target *float64, defaultValue float64) *FormData {
|
||||
form.inches(key, target)
|
||||
if *target == -math.MaxFloat64 {
|
||||
*target = defaultValue
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
// MandatoryInches binds a form field to a float64 variable. It populates
|
||||
// an error if the value cannot be computed back to inches, is empty, or the
|
||||
// "key" does not exist.
|
||||
//
|
||||
// var foo float64
|
||||
//
|
||||
// ctx.FormData().MandatoryInches("foo", &foo)
|
||||
func (form *FormData) MandatoryInches(key string, target *float64) *FormData {
|
||||
val, ok := form.values[key]
|
||||
if !ok || val[0] == "" {
|
||||
form.append(
|
||||
fmt.Errorf("form field '%s' is required", key),
|
||||
)
|
||||
return form
|
||||
}
|
||||
return form.inches(key, target)
|
||||
}
|
||||
|
||||
// inches tries to compute a string value to inches.
|
||||
func (form *FormData) inches(key string, target *float64) *FormData {
|
||||
var value string
|
||||
form.mustValue(key, &value, "")
|
||||
|
||||
if value == "" {
|
||||
*target = -math.MaxFloat64
|
||||
return form
|
||||
}
|
||||
|
||||
for _, unit := range []string{"pt", "px", "in", "mm", "cm", "pc"} {
|
||||
if !strings.HasSuffix(value, unit) {
|
||||
continue
|
||||
}
|
||||
|
||||
val, err := strconv.ParseFloat(strings.TrimSuffix(value, unit), 64)
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
)
|
||||
return form
|
||||
}
|
||||
|
||||
switch unit {
|
||||
case "pt":
|
||||
*target = val * (1.0 / 72.0)
|
||||
case "px":
|
||||
*target = val * (1.0 / 96.0)
|
||||
case "in":
|
||||
*target = val
|
||||
case "mm":
|
||||
*target = val * (1.0 / 25.4)
|
||||
case "cm":
|
||||
*target = val * (1.0 / 2.54)
|
||||
case "pc":
|
||||
*target = val * (1.0 / 6.0)
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
val, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
)
|
||||
return form
|
||||
}
|
||||
|
||||
*target = val
|
||||
return form
|
||||
}
|
||||
|
||||
// Custom helps to define a custom binding function for a form field.
|
||||
//
|
||||
// var foo map[string]string
|
||||
@@ -215,7 +303,7 @@ func (form *FormData) Path(filename string, target *string) *FormData {
|
||||
return form.path(filename, target)
|
||||
}
|
||||
|
||||
// MandatoryPath binds the absolute path ofa form data file to a string
|
||||
// MandatoryPath binds the absolute path of a form data file to a string
|
||||
// variable. It populates an error if the file does not exist.
|
||||
//
|
||||
// var path string
|
||||
@@ -260,7 +348,7 @@ func (form *FormData) MandatoryContent(filename string, target *string) *FormDat
|
||||
return form.readFile(path, filename, target)
|
||||
}
|
||||
|
||||
// Paths binds the absolute paths of form data files, according to a list of
|
||||
// Paths bind the absolute paths of form data files, according to a list of
|
||||
// file extensions, to a string slice variable.
|
||||
//
|
||||
// var paths []string
|
||||
@@ -291,7 +379,7 @@ func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *For
|
||||
return form
|
||||
}
|
||||
|
||||
// paths binds the absolute paths of form data files, according to a list of
|
||||
// paths bind the absolute paths of form data files, according to a list of
|
||||
// file extensions, to a string slice variable.
|
||||
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||
for filename, path := range form.files {
|
||||
@@ -304,7 +392,7 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/139.
|
||||
sort.Strings(*target)
|
||||
sort.Sort(gotenberg.AlphanumericSort(*target))
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -768,6 +769,333 @@ func TestFormData_MandatoryDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormData_Inches(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
form *FormData
|
||||
defaultValue float64
|
||||
expect float64
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "key does not exist, fallback to default zero value",
|
||||
form: &FormData{},
|
||||
defaultValue: 0.0,
|
||||
expect: 0.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does not exist, fallback to default value",
|
||||
form: &FormData{},
|
||||
defaultValue: 2.5,
|
||||
expect: 2.5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist, but empty value, fallback to default value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 0.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist, value has a unit, but the rest is not float64 compatible",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"foomm",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 0.0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist, but value has no unit and is invalid",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"foo",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 0.0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with a pt value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"72pt",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with a px value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"96px",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with an in value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"1in",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with a mm value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"25.4mm",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with a cm value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"2.54cm",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with a pc value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"6pc",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with no unit in the value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"100",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 100,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
var actual float64
|
||||
|
||||
tc.form.Inches("foo", &actual, tc.defaultValue)
|
||||
|
||||
if fmt.Sprintf("%.1f", actual) != fmt.Sprintf("%.1f", tc.expect) {
|
||||
t.Errorf("expected %.1f but got %.1f", tc.expect, actual)
|
||||
}
|
||||
|
||||
if tc.expectError && tc.form.errors == nil {
|
||||
t.Fatal("expected error but got none", tc.form.errors)
|
||||
}
|
||||
|
||||
if !tc.expectError && tc.form.errors != nil {
|
||||
t.Fatalf("expected no error but got: %v", tc.form.errors)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormData_MandatoryInches(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
form *FormData
|
||||
expect float64
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "missing mandatory key",
|
||||
form: &FormData{},
|
||||
expect: 0.0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "mandatory value is empty",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 0.0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "mandatory value has a unit, but the rest is not float64 compatible",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"foomm",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 0.0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "mandatory value has no unit and is invalid",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"foo",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 0.0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "a pt mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"72pt",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "a px mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"96px",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "an in mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"1in",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "a mm mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"25.4mm",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "a cm mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"2.54cm",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "a pc mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"6pc",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "no unit in the mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"100",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 100,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
var actual float64
|
||||
|
||||
tc.form.MandatoryInches("foo", &actual)
|
||||
|
||||
if fmt.Sprintf("%.1f", actual) != fmt.Sprintf("%.1f", tc.expect) {
|
||||
t.Errorf("expected %.1f but got %.1f", tc.expect, actual)
|
||||
}
|
||||
|
||||
if tc.expectError && tc.form.errors == nil {
|
||||
t.Fatal("expected error but got none", tc.form.errors)
|
||||
}
|
||||
|
||||
if !tc.expectError && tc.form.errors != nil {
|
||||
t.Fatalf("expected no error but got: %v", tc.form.errors)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormData_Custom(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
@@ -1097,36 +1425,36 @@ func TestFormData_Content(t *testing.T) {
|
||||
scenario: "file does exist without file extension",
|
||||
form: &FormData{
|
||||
files: map[string]string{
|
||||
"foo": "/tests/test/testdata/api/sample1.txt",
|
||||
"foo": "testdata/sample.txt",
|
||||
},
|
||||
},
|
||||
filename: "foo",
|
||||
defaultValue: "",
|
||||
expect: "foo",
|
||||
expect: "This is a text from a text file.",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "file does exist with an uppercase file extension",
|
||||
form: &FormData{
|
||||
files: map[string]string{
|
||||
"foo.TXT": "/tests/test/testdata/api/sample1.txt",
|
||||
"foo.TXT": "testdata/sample.txt",
|
||||
},
|
||||
},
|
||||
filename: "foo.txt",
|
||||
defaultValue: "",
|
||||
expect: "foo",
|
||||
expect: "This is a text from a text file.",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "file does exist without a lowercase file extension",
|
||||
form: &FormData{
|
||||
files: map[string]string{
|
||||
"foo.txt": "/tests/test/testdata/api/sample1.txt",
|
||||
"foo.txt": "testdata/sample.txt",
|
||||
},
|
||||
},
|
||||
filename: "foo.txt",
|
||||
defaultValue: "",
|
||||
expect: "foo",
|
||||
expect: "This is a text from a text file.",
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
@@ -1191,33 +1519,33 @@ func TestFormData_MandatoryContent(t *testing.T) {
|
||||
scenario: "mandatory file does exist without file extension",
|
||||
form: &FormData{
|
||||
files: map[string]string{
|
||||
"foo": "/tests/test/testdata/api/sample1.txt",
|
||||
"foo": "testdata/sample.txt",
|
||||
},
|
||||
},
|
||||
filename: "foo",
|
||||
expect: "foo",
|
||||
expect: "This is a text from a text file.",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "mandatory file does exist with an uppercase file extension",
|
||||
form: &FormData{
|
||||
files: map[string]string{
|
||||
"foo.TXT": "/tests/test/testdata/api/sample1.txt",
|
||||
"foo.TXT": "testdata/sample.txt",
|
||||
},
|
||||
},
|
||||
filename: "foo.txt",
|
||||
expect: "foo",
|
||||
expect: "This is a text from a text file.",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "mandatory file does exist without a lowercase file extension",
|
||||
form: &FormData{
|
||||
files: map[string]string{
|
||||
"foo.txt": "/tests/test/testdata/api/sample1.txt",
|
||||
"foo.txt": "testdata/sample.txt",
|
||||
},
|
||||
},
|
||||
filename: "foo.txt",
|
||||
expect: "foo",
|
||||
expect: "This is a text from a text file.",
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -10,14 +11,21 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// ErrAsyncProcess happens when a handler or middleware handles a request in an
|
||||
// asynchronous fashion.
|
||||
var ErrAsyncProcess = errors.New("async process")
|
||||
var (
|
||||
// ErrAsyncProcess happens when a handler or middleware handles a request
|
||||
// in an asynchronous fashion.
|
||||
ErrAsyncProcess = errors.New("async process")
|
||||
|
||||
// ErrNoOutputFile happens when a handler or middleware handles a request
|
||||
// without sending any output file.
|
||||
ErrNoOutputFile = errors.New("no output file")
|
||||
)
|
||||
|
||||
// ParseError parses an error and returns the corresponding HTTP status and
|
||||
// HTTP message.
|
||||
@@ -32,6 +40,26 @@ func ParseError(err error) (int, string) {
|
||||
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrFiltered) {
|
||||
return http.StatusForbidden, http.StatusText(http.StatusForbidden)
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
|
||||
return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfSplitModeNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues"
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues"
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
|
||||
}
|
||||
|
||||
var httpErr HttpError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr.HttpError()
|
||||
@@ -58,7 +86,7 @@ func httpErrorHandler() echo.HTTPErrorHandler {
|
||||
}
|
||||
|
||||
// latencyMiddleware sets the start time in the [echo.Context] under
|
||||
// "startTime". Its value will be used later to calculate a request latency.
|
||||
// "startTime". Its value will be used later to calculate request latency.
|
||||
//
|
||||
// startTime := c.Get("startTime").(time.Time)
|
||||
func latencyMiddleware() echo.MiddlewareFunc {
|
||||
@@ -81,7 +109,7 @@ func latencyMiddleware() echo.MiddlewareFunc {
|
||||
// rootPath := c.Get("rootPath").(string)
|
||||
// healthURI := fmt.Sprintf("%s/health", rootPath)
|
||||
//
|
||||
// // Skip the middleware if health check URI.
|
||||
// // Skip the middleware if it's the health check URI.
|
||||
// if c.Request().RequestURI == healthURI {
|
||||
// // Call the next middleware in the chain.
|
||||
// return next(c)
|
||||
@@ -90,7 +118,6 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
c.Set("rootPath", rootPath)
|
||||
|
||||
// Call the next middleware in the chain.
|
||||
return next(c)
|
||||
}
|
||||
@@ -134,9 +161,12 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
|
||||
trace := c.Get("trace").(string)
|
||||
rootPath := c.Get("rootPath").(string)
|
||||
|
||||
// Create the request logger and add it to our locals.
|
||||
reqLogger := logger.With(zap.String("trace", trace))
|
||||
c.Set("logger", reqLogger.Named(func() string {
|
||||
// Create the application logger and add it to our locals.
|
||||
appLogger := logger.
|
||||
With(zap.String("log_type", "application")).
|
||||
With(zap.String("trace", trace))
|
||||
|
||||
c.Set("logger", appLogger.Named(func() string {
|
||||
return strings.ReplaceAll(
|
||||
strings.ReplaceAll(c.Request().URL.Path, rootPath, ""),
|
||||
"/",
|
||||
@@ -150,6 +180,11 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
|
||||
c.Error(err)
|
||||
}
|
||||
|
||||
// Create the access logger.
|
||||
accessLogger := logger.
|
||||
With(zap.String("log_type", "access")).
|
||||
With(zap.String("trace", trace))
|
||||
|
||||
for _, path := range disableLoggingForPaths {
|
||||
URI := fmt.Sprintf("%s%s", rootPath, path)
|
||||
|
||||
@@ -185,9 +220,9 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
|
||||
fields[11] = zap.Int64("bytes_out", c.Response().Size)
|
||||
|
||||
if err != nil {
|
||||
reqLogger.Error(err.Error(), fields...)
|
||||
accessLogger.Error(err.Error(), fields...)
|
||||
} else {
|
||||
reqLogger.Info("request handled", fields...)
|
||||
accessLogger.Info("request handled", fields...)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -195,21 +230,34 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
|
||||
}
|
||||
}
|
||||
|
||||
// contextMiddleware, a middleware for "multipart/form-data" requests, sets the
|
||||
// basicAuthMiddleware manages basic authentication.
|
||||
func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
|
||||
return middleware.BasicAuth(func(u string, p string, e echo.Context) (bool, error) {
|
||||
if subtle.ConstantTimeCompare([]byte(u), []byte(username)) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
// contextMiddleware, middleware for "multipart/form-data" requests, sets the
|
||||
// [Context] and related context.CancelFunc in the [echo.Context] under
|
||||
// "context" and "cancel". If the process is synchronous, it also handles the
|
||||
// result of a "multipart/form-data" request.
|
||||
//
|
||||
// ctx := c.Get("context").(*api.Context)
|
||||
// cancel := c.Get("cancel").(context.CancelFunc)
|
||||
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration) echo.MiddlewareFunc {
|
||||
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
logger := c.Get("logger").(*zap.Logger)
|
||||
traceHeader := c.Get("traceHeader").(string)
|
||||
trace := c.Get("trace").(string)
|
||||
|
||||
// We create a context with a timeout so that underlying processes are
|
||||
// able to stop early and handle correctly a timeout scenario.
|
||||
ctx, cancel, err := newContext(c, logger, fs, timeout)
|
||||
// able to stop early and correctly handle a timeout scenario.
|
||||
ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg, traceHeader, trace)
|
||||
if err != nil {
|
||||
cancel()
|
||||
|
||||
@@ -230,6 +278,13 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration) echo.Mid
|
||||
|
||||
defer cancel()
|
||||
|
||||
if errors.Is(err, ErrNoOutputFile) {
|
||||
// A middleware/handler tells us that it's handling the process
|
||||
// in an asynchronous fashion. Therefore, we must not cancel
|
||||
// the context nor send an output file.
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestParseError(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
err error
|
||||
expectStatus int
|
||||
expectMessage string
|
||||
}{
|
||||
{
|
||||
err: echo.ErrInternalServerError,
|
||||
expectStatus: http.StatusInternalServerError,
|
||||
expectMessage: http.StatusText(http.StatusInternalServerError),
|
||||
},
|
||||
{
|
||||
err: context.DeadlineExceeded,
|
||||
expectStatus: http.StatusServiceUnavailable,
|
||||
expectMessage: http.StatusText(http.StatusServiceUnavailable),
|
||||
},
|
||||
{
|
||||
err: WrapError(
|
||||
errors.New("foo"),
|
||||
NewSentinelHttpError(http.StatusBadRequest, "foo"),
|
||||
),
|
||||
expectStatus: http.StatusBadRequest,
|
||||
expectMessage: "foo",
|
||||
},
|
||||
} {
|
||||
actualStatus, actualMessage := ParseError(tc.err)
|
||||
|
||||
if actualStatus != tc.expectStatus {
|
||||
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, actualStatus)
|
||||
}
|
||||
|
||||
if actualMessage != tc.expectMessage {
|
||||
t.Errorf("test %d: expected message '%s' but got '%s'", i, tc.expectMessage, actualMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHttpErrorHandler(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
err error
|
||||
expectStatus int
|
||||
expectMessage string
|
||||
}{
|
||||
{
|
||||
err: echo.ErrInternalServerError,
|
||||
expectStatus: http.StatusInternalServerError,
|
||||
expectMessage: http.StatusText(http.StatusInternalServerError),
|
||||
},
|
||||
{
|
||||
err: context.DeadlineExceeded,
|
||||
expectStatus: http.StatusServiceUnavailable,
|
||||
expectMessage: http.StatusText(http.StatusServiceUnavailable),
|
||||
},
|
||||
{
|
||||
err: WrapError(
|
||||
errors.New("foo"),
|
||||
NewSentinelHttpError(http.StatusBadRequest, "foo"),
|
||||
),
|
||||
expectStatus: http.StatusBadRequest,
|
||||
expectMessage: "foo",
|
||||
},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(request, recorder)
|
||||
c.Set("logger", zap.NewNop())
|
||||
|
||||
handler := httpErrorHandler()
|
||||
handler(tc.err, c)
|
||||
|
||||
contentType := recorder.Header().Get(echo.HeaderContentType)
|
||||
if contentType != echo.MIMETextPlainCharsetUTF8 {
|
||||
t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8, contentType)
|
||||
}
|
||||
|
||||
// Note: we cannot test the trace header in the response here, as it is set in the trace middleware.
|
||||
|
||||
if recorder.Code != tc.expectStatus {
|
||||
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Body.String() != tc.expectMessage {
|
||||
t.Errorf("test %d: expected message '%s' but got '%s'", i, tc.expectMessage, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatencyMiddleware(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(request, recorder)
|
||||
|
||||
err := latencyMiddleware()(
|
||||
func(c echo.Context) error {
|
||||
return nil
|
||||
},
|
||||
)(c)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
startTime := c.Get("startTime").(time.Time)
|
||||
now := time.Now()
|
||||
|
||||
if now.Before(startTime) {
|
||||
t.Errorf("expected start time %s to be < %s", startTime, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootPathMiddleware(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(request, recorder)
|
||||
|
||||
err := rootPathMiddleware("foo")(
|
||||
func(c echo.Context) error {
|
||||
return nil
|
||||
},
|
||||
)(c)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
rootPath := c.Get("rootPath").(string)
|
||||
|
||||
if rootPath != "foo" {
|
||||
t.Errorf("expected '%s' but got '%s", "foo", rootPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceMiddleware(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
trace string
|
||||
}{
|
||||
{
|
||||
trace: "foo",
|
||||
},
|
||||
{
|
||||
trace: "",
|
||||
},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(request, recorder)
|
||||
|
||||
if tc.trace != "" {
|
||||
c.Request().Header.Set("Gotenberg-Trace", tc.trace)
|
||||
}
|
||||
|
||||
err := traceMiddleware("Gotenberg-Trace")(
|
||||
func(c echo.Context) error {
|
||||
return nil
|
||||
},
|
||||
)(c)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
trace := c.Get("trace").(string)
|
||||
|
||||
if trace == "" {
|
||||
t.Errorf("test %d: expected non empty trace in context", i)
|
||||
}
|
||||
|
||||
if tc.trace != "" && trace != tc.trace {
|
||||
t.Errorf("test %d: expected context trace '%s' but got '%s'", i, tc.trace, trace)
|
||||
}
|
||||
|
||||
if tc.trace == "" && trace == tc.trace {
|
||||
t.Errorf("test %d: expected context trace different from '%s' but got '%s'", i, tc.trace, trace)
|
||||
}
|
||||
|
||||
responseTrace := recorder.Header().Get("Gotenberg-Trace")
|
||||
|
||||
if tc.trace != "" && responseTrace != tc.trace {
|
||||
t.Errorf("test %d: expected header trace '%s' but got '%s'", i, tc.trace, responseTrace)
|
||||
}
|
||||
|
||||
if tc.trace == "" && responseTrace == tc.trace {
|
||||
t.Errorf("test %d: expected header trace different from '%s' but got '%s'", i, tc.trace, responseTrace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerMiddleware(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
request *http.Request
|
||||
next echo.HandlerFunc
|
||||
skipLogging bool
|
||||
}{
|
||||
{
|
||||
request: httptest.NewRequest(http.MethodGet, "/", nil),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
}(),
|
||||
},
|
||||
{
|
||||
request: httptest.NewRequest(http.MethodGet, "/health", nil),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
skipLogging: true,
|
||||
},
|
||||
{
|
||||
request: httptest.NewRequest(http.MethodGet, "/health", nil),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(tc.request, recorder)
|
||||
c.Set("startTime", time.Now())
|
||||
c.Set("trace", "foo")
|
||||
c.Set("rootPath", "/")
|
||||
|
||||
var disableLoggingForPaths []string
|
||||
if tc.skipLogging {
|
||||
disableLoggingForPaths = append(disableLoggingForPaths, tc.request.RequestURI)
|
||||
}
|
||||
|
||||
err := loggerMiddleware(zap.NewNop(), disableLoggingForPaths)(tc.next)(c)
|
||||
if err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMiddleware(t *testing.T) {
|
||||
buildMultipartFormDataRequest := func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err := writer.WriteField("foo", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
for i, tc := range []struct {
|
||||
request *http.Request
|
||||
next echo.HandlerFunc
|
||||
expectErr bool
|
||||
expectStatus int
|
||||
expectContentType string
|
||||
expectFilename string
|
||||
}{
|
||||
{
|
||||
request: httptest.NewRequest(http.MethodGet, "/", nil),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
request: buildMultipartFormDataRequest(),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return ErrAsyncProcess
|
||||
}
|
||||
}(),
|
||||
expectStatus: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
request: buildMultipartFormDataRequest(),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
request: buildMultipartFormDataRequest(),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
request: func() *http.Request {
|
||||
req := buildMultipartFormDataRequest()
|
||||
req.Header.Set("Gotenberg-Output-Filename", "foo")
|
||||
|
||||
return req
|
||||
}(),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*Context)
|
||||
ctx.outputPaths = []string{
|
||||
"/tests/test/testdata/api/sample2.pdf",
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
expectStatus: http.StatusOK,
|
||||
expectContentType: "application/pdf",
|
||||
expectFilename: "foo.pdf",
|
||||
},
|
||||
{
|
||||
request: buildMultipartFormDataRequest(),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*Context)
|
||||
ctx.outputPaths = []string{
|
||||
"/tests/test/testdata/api/sample1.txt",
|
||||
"/tests/test/testdata/api/sample2.pdf",
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
expectStatus: http.StatusOK,
|
||||
expectContentType: "application/zip",
|
||||
},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(tc.request, recorder)
|
||||
c.Set("logger", zap.NewNop())
|
||||
c.Set("trace", "foo")
|
||||
c.Set("startTime", time.Now())
|
||||
|
||||
err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second)(tc.next)(c)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if recorder.Code != tc.expectStatus {
|
||||
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, recorder.Code)
|
||||
}
|
||||
|
||||
if tc.expectStatus == http.StatusNoContent {
|
||||
continue
|
||||
}
|
||||
|
||||
contentType := recorder.Header().Get(echo.HeaderContentType)
|
||||
if contentType != tc.expectContentType {
|
||||
t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, tc.expectContentType, contentType)
|
||||
}
|
||||
|
||||
contentDisposition := recorder.Header().Get(echo.HeaderContentDisposition)
|
||||
if !strings.Contains(contentDisposition, tc.expectFilename) {
|
||||
t.Errorf("test %d: expected %s '%s' to contain '%s'", i, echo.HeaderContentDisposition, contentDisposition, tc.expectFilename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardTimeoutMiddleware(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
next echo.HandlerFunc
|
||||
timeout time.Duration
|
||||
expectErr bool
|
||||
expectHardTimeout bool
|
||||
}{
|
||||
{
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
timeout: time.Duration(100) * time.Millisecond,
|
||||
},
|
||||
{
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
panic("foo")
|
||||
}
|
||||
}(),
|
||||
timeout: time.Duration(100) * time.Millisecond,
|
||||
expectErr: true,
|
||||
expectHardTimeout: true,
|
||||
},
|
||||
{
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
}(),
|
||||
timeout: time.Duration(100) * time.Millisecond,
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
time.Sleep(time.Duration(200) * time.Millisecond)
|
||||
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
timeout: time.Duration(100) * time.Millisecond,
|
||||
expectErr: true,
|
||||
expectHardTimeout: true,
|
||||
},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(request, recorder)
|
||||
c.Set("logger", zap.NewNop())
|
||||
|
||||
err := hardTimeoutMiddleware(tc.timeout)(tc.next)(c)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
var isHardTimeout bool
|
||||
if err != nil {
|
||||
isHardTimeout = strings.Contains(err.Error(), "hard timeout")
|
||||
}
|
||||
|
||||
if tc.expectHardTimeout && !isHardTimeout {
|
||||
t.Errorf("test %d: expected hard timeout error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectHardTimeout && isHardTimeout {
|
||||
t.Errorf("test %d: expected no hard timeout error but got one: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// ContextMock is a helper for tests.
|
||||
@@ -52,7 +54,7 @@ func (ctx *ContextMock) SetFiles(files map[string]string) {
|
||||
ctx.files = files
|
||||
}
|
||||
|
||||
// SetCancelled sets if the context is cancelled or not.
|
||||
// SetCancelled sets if the context is canceled or not.
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.SetCancelled(true)
|
||||
@@ -76,12 +78,28 @@ func (ctx *ContextMock) SetLogger(logger *zap.Logger) {
|
||||
ctx.logger = logger
|
||||
}
|
||||
|
||||
// SetEchoContext sets the echo.Context.
|
||||
// SetEchoContext sets the [echo.Context].
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.setEchoContext(c)
|
||||
func (ctx *ContextMock) SetEchoContext(c echo.Context) {
|
||||
ctx.Context.echoCtx = c
|
||||
ctx.echoCtx = c
|
||||
}
|
||||
|
||||
// SetMkdirAll sets the [gotenberg.MkdirAll].
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.SetMkdirAll(mkdirAll)
|
||||
func (ctx *ContextMock) SetMkdirAll(mkdirAll gotenberg.MkdirAll) {
|
||||
ctx.mkdirAll = mkdirAll
|
||||
}
|
||||
|
||||
// SetPathRename sets the [gotenberg.PathRename].
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.setPathRename(rename)
|
||||
func (ctx *ContextMock) SetPathRename(rename gotenberg.PathRename) {
|
||||
ctx.pathRename = rename
|
||||
}
|
||||
|
||||
// RouterMock is a mock for the [Router] interface.
|
||||
@@ -102,7 +120,7 @@ func (provider *MiddlewareProviderMock) Middlewares() ([]Middleware, error) {
|
||||
return provider.MiddlewaresMock()
|
||||
}
|
||||
|
||||
// HealthCheckerMock is mock for the [HealthChecker] interface.
|
||||
// HealthCheckerMock is a mock for the [HealthChecker] interface.
|
||||
type HealthCheckerMock struct {
|
||||
ChecksMock func() ([]health.CheckerOption, error)
|
||||
ReadyMock func() error
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestContextMock_SetDirPath(t *testing.T) {
|
||||
mock := &ContextMock{&Context{}}
|
||||
mock.SetDirPath("/foo")
|
||||
|
||||
actual := mock.dirPath
|
||||
expect := "/foo"
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_DirPath(t *testing.T) {
|
||||
mock := &ContextMock{&Context{}}
|
||||
mock.SetDirPath("/foo")
|
||||
|
||||
actual := mock.DirPath()
|
||||
expect := "/foo"
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_SetValues(t *testing.T) {
|
||||
mock := &ContextMock{&Context{}}
|
||||
mock.SetValues(map[string][]string{
|
||||
"foo": {"foo"},
|
||||
})
|
||||
|
||||
actual := mock.values
|
||||
expect := map[string][]string{
|
||||
"foo": {"foo"},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(actual, expect) {
|
||||
t.Errorf("expected %+v but got: %+v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_SetFiles(t *testing.T) {
|
||||
mock := &ContextMock{&Context{}}
|
||||
mock.SetFiles(map[string]string{
|
||||
"foo": "/foo",
|
||||
})
|
||||
|
||||
actual := mock.files
|
||||
expect := map[string]string{
|
||||
"foo": "/foo",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(actual, expect) {
|
||||
t.Errorf("expected %+v but got: %+v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_SetCancelled(t *testing.T) {
|
||||
mock := &ContextMock{&Context{}}
|
||||
mock.SetCancelled(true)
|
||||
|
||||
actual := mock.cancelled
|
||||
|
||||
if !actual {
|
||||
t.Errorf("expected %t but got %t", true, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_OutputPaths(t *testing.T) {
|
||||
mock := ContextMock{
|
||||
&Context{
|
||||
outputPaths: []string{"/foo"},
|
||||
},
|
||||
}
|
||||
|
||||
actual := mock.OutputPaths()
|
||||
expect := []string{"/foo"}
|
||||
|
||||
if !reflect.DeepEqual(actual, expect) {
|
||||
t.Errorf("expected %+v but got: %+v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_SetLogger(t *testing.T) {
|
||||
mock := ContextMock{&Context{}}
|
||||
|
||||
expect := zap.NewNop()
|
||||
mock.SetLogger(expect)
|
||||
|
||||
actual := mock.logger
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_SetEchoContext(t *testing.T) {
|
||||
mock := ContextMock{&Context{}}
|
||||
|
||||
expect := echo.New().NewContext(nil, nil)
|
||||
mock.SetEchoContext(expect)
|
||||
|
||||
actual := mock.echoCtx
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterMock(t *testing.T) {
|
||||
mock := &RouterMock{
|
||||
RoutesMock: func() ([]Route, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.Routes()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from RouterMock.Routes, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareProviderMock(t *testing.T) {
|
||||
mock := &MiddlewareProviderMock{
|
||||
MiddlewaresMock: func() ([]Middleware, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.Middlewares()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from MiddlewareProviderMock.Middlewares, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckerMock(t *testing.T) {
|
||||
mock := &HealthCheckerMock{
|
||||
ChecksMock: func() ([]health.CheckerOption, error) {
|
||||
return nil, nil
|
||||
},
|
||||
ReadyMock: func() error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.Checks()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from HealthCheckerMock.Checks, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.Ready()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from HealthCheckerMock.Ready, but got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -13,8 +12,11 @@ import (
|
||||
|
||||
"github.com/chromedp/cdproto/fetch"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
@@ -39,8 +41,8 @@ type browserArguments struct {
|
||||
wsUrlReadTimeout time.Duration
|
||||
|
||||
// Tasks specific.
|
||||
allowList *regexp.Regexp
|
||||
denyList *regexp.Regexp
|
||||
allowList *regexp2.Regexp
|
||||
denyList *regexp2.Regexp
|
||||
clearCache bool
|
||||
clearCookies bool
|
||||
disableJavaScript bool
|
||||
@@ -62,7 +64,7 @@ func newChromiumBrowser(arguments browserArguments) browser {
|
||||
b := &chromiumBrowser{
|
||||
initialCtx: context.Background(),
|
||||
arguments: arguments,
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
|
||||
}
|
||||
b.isStarted.Store(false)
|
||||
|
||||
@@ -90,6 +92,11 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error {
|
||||
// https://github.com/puppeteer/puppeteer/issues/2410
|
||||
chromedp.Flag("font-render-hinting", "none"),
|
||||
chromedp.UserDataDir(b.userProfileDirPath),
|
||||
// See https://github.com/gotenberg/gotenberg/issues/831.
|
||||
chromedp.Flag("disable-pdf-tagging", true),
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1177.
|
||||
chromedp.Flag("no-zygote", true),
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
)
|
||||
|
||||
if b.arguments.incognito {
|
||||
@@ -160,21 +167,61 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
|
||||
|
||||
// Always remove the user profile directory created by Chromium.
|
||||
copyUserProfileDirPath := b.userProfileDirPath
|
||||
defer func(userProfileDirPath string) {
|
||||
expirationTime := time.Now()
|
||||
defer func(userProfileDirPath string, expirationTime time.Time) {
|
||||
// See:
|
||||
// https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChrome/chrome-cleanup.sh
|
||||
// https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChromium/chrome-cleanup.sh
|
||||
|
||||
// Clean up stuck processes.
|
||||
ps, err := process.Processes()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("list processes: %v", err))
|
||||
} else {
|
||||
for _, p := range ps {
|
||||
func() {
|
||||
cmdline, err := p.Cmdline()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.Contains(cmdline, "chromium/chromium") && !strings.Contains(cmdline, "chrome/chrome") {
|
||||
return
|
||||
}
|
||||
|
||||
killCtx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
err = p.KillWithContext(killCtx)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("kill process: %v", err))
|
||||
} else {
|
||||
logger.Debug(fmt.Sprintf("Chromium process %d killed", p.Pid))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
// FIXME: Chromium seems to recreate the user profile directory
|
||||
// right after its deletion if we do not wait a certain amount
|
||||
// of time before re-deleting it.
|
||||
// of time before deleting it.
|
||||
<-time.After(10 * time.Second)
|
||||
|
||||
err := os.RemoveAll(userProfileDirPath)
|
||||
err = os.RemoveAll(userProfileDirPath)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err))
|
||||
} else {
|
||||
logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
|
||||
// Also, remove Chromium-specific files in the temporary directory.
|
||||
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
}()
|
||||
}(copyUserProfileDirPath)
|
||||
}(copyUserProfileDirPath, expirationTime)
|
||||
|
||||
b.ctxMu.Lock()
|
||||
defer b.ctxMu.Unlock()
|
||||
@@ -224,15 +271,18 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
|
||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
|
||||
setCookiesActionFunc(logger, options.Cookies),
|
||||
userAgentOverride(logger, options.UserAgent),
|
||||
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
|
||||
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
|
||||
forceExactColorsActionFunc(),
|
||||
forceExactColorsActionFunc(logger, options.PrintBackground),
|
||||
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
// PDF specific.
|
||||
printToPdfActionFunc(logger, outputPath, options),
|
||||
// Teardown.
|
||||
page.Close(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,15 +296,19 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
|
||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
|
||||
setCookiesActionFunc(logger, options.Cookies),
|
||||
userAgentOverride(logger, options.UserAgent),
|
||||
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
|
||||
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
|
||||
forceExactColorsActionFunc(),
|
||||
forceExactColorsActionFunc(logger, true),
|
||||
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
// Screenshot specific.
|
||||
setDeviceMetricsOverride(logger, options.Width, options.Height),
|
||||
captureScreenshotActionFunc(logger, outputPath, options),
|
||||
// Teardown.
|
||||
page.Close(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -263,20 +317,17 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
||||
return errors.New("browser not started, cannot handle tasks")
|
||||
}
|
||||
|
||||
// We validate the "main" URL against our allow / deny lists.
|
||||
if !b.arguments.allowList.MatchString(url) {
|
||||
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", url, ErrUrlNotAuthorized)
|
||||
}
|
||||
|
||||
if b.arguments.denyList.String() != "" && b.arguments.denyList.MatchString(url) {
|
||||
return fmt.Errorf("'%s' matches the expression from the denied list: %w", url, ErrUrlNotAuthorized)
|
||||
}
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter URL: %w", err)
|
||||
}
|
||||
|
||||
b.ctxMu.RLock()
|
||||
defer b.ctxMu.RUnlock()
|
||||
|
||||
@@ -286,18 +337,36 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
||||
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx)
|
||||
defer taskCancel()
|
||||
|
||||
// We validate all others requests against our allow / deny lists.
|
||||
// If a request does not pass the validation, we make it fail.
|
||||
listenForEventRequestPaused(taskCtx, logger, b.arguments.allowList, b.arguments.denyList)
|
||||
// We validate all other requests against our allowed / deny lists.
|
||||
// If a request does not pass the validation, we make it fail. It also set
|
||||
// the extra HTTP headers, if any.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
|
||||
allowList: b.arguments.allowList,
|
||||
denyList: b.arguments.denyList,
|
||||
extraHttpHeaders: options.ExtraHttpHeaders,
|
||||
})
|
||||
|
||||
var (
|
||||
invalidHttpStatusCode error
|
||||
invalidHttpStatusCodeMu sync.RWMutex
|
||||
invalidHttpStatusCode error
|
||||
invalidHttpStatusCodeMu sync.RWMutex
|
||||
invalidResourceHttpStatusCode error
|
||||
invalidResourceHttpStatusCodeMu sync.RWMutex
|
||||
)
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/613.
|
||||
if len(options.FailOnHttpStatusCodes) != 0 {
|
||||
listenForEventResponseReceived(taskCtx, logger, url, options.FailOnHttpStatusCodes, &invalidHttpStatusCode, &invalidHttpStatusCodeMu)
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/613.
|
||||
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
if len(options.FailOnHttpStatusCodes) != 0 || len(options.FailOnResourceHttpStatusCodes) != 0 {
|
||||
listenForEventResponseReceived(taskCtx, logger, eventResponseReceivedOptions{
|
||||
mainPageUrl: url,
|
||||
failOnHttpStatusCodes: options.FailOnHttpStatusCodes,
|
||||
invalidHttpStatusCode: &invalidHttpStatusCode,
|
||||
invalidHttpStatusCodeMu: &invalidHttpStatusCodeMu,
|
||||
failOnResourceOnHttpStatusCode: options.FailOnResourceHttpStatusCodes,
|
||||
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
|
||||
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -310,7 +379,25 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
||||
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
|
||||
}
|
||||
|
||||
err := chromedp.Run(taskCtx, tasks...)
|
||||
var (
|
||||
loadingFailed error
|
||||
loadingFailedMu sync.RWMutex
|
||||
resourceLoadingFailed error
|
||||
resourceLoadingFailedMu sync.RWMutex
|
||||
)
|
||||
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/913.
|
||||
// https://github.com/gotenberg/gotenberg/issues/959.
|
||||
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
listenForEventLoadingFailed(taskCtx, logger, eventLoadingFailedOptions{
|
||||
loadingFailed: &loadingFailed,
|
||||
loadingFailedMu: &loadingFailedMu,
|
||||
resourceLoadingFailed: &resourceLoadingFailed,
|
||||
resourceLoadingFailedMu: &resourceLoadingFailedMu,
|
||||
})
|
||||
|
||||
err = chromedp.Run(taskCtx, tasks...)
|
||||
if err != nil {
|
||||
errMessage := err.Error()
|
||||
|
||||
@@ -337,6 +424,14 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
||||
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
invalidResourceHttpStatusCodeMu.RLock()
|
||||
defer invalidResourceHttpStatusCodeMu.RUnlock()
|
||||
|
||||
if invalidResourceHttpStatusCode != nil {
|
||||
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
consoleExceptionsMu.RLock()
|
||||
defer consoleExceptionsMu.RUnlock()
|
||||
@@ -345,6 +440,23 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
||||
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
|
||||
}
|
||||
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/913.
|
||||
// https://github.com/gotenberg/gotenberg/issues/959.
|
||||
loadingFailedMu.RLock()
|
||||
defer loadingFailedMu.RUnlock()
|
||||
|
||||
if loadingFailed != nil {
|
||||
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
if options.FailOnResourceLoadingFailed {
|
||||
if resourceLoadingFailed != nil {
|
||||
return fmt.Errorf("%v: %w", resourceLoadingFailed, ErrResourceLoadingFailed)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/dlclark/regexp2"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -20,12 +25,8 @@ func init() {
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrUrlNotAuthorized happens if a URL is not acceptable according to the
|
||||
// allowed/denied lists.
|
||||
ErrUrlNotAuthorized = errors.New("URL not authorized")
|
||||
|
||||
// ErrInvalidEmulatedMediaType happens if the emulated media type is not
|
||||
// "screen" nor "print". Empty value are allowed though.
|
||||
// "screen" nor "print". Empty value is allowed, though.
|
||||
ErrInvalidEmulatedMediaType = errors.New("invalid emulated media type")
|
||||
|
||||
// ErrInvalidEvaluationExpression happens if an evaluation expression
|
||||
@@ -37,14 +38,25 @@ var (
|
||||
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
||||
|
||||
// ErrInvalidHttpStatusCode happens when the status code from the main page
|
||||
// matches with one of the entry in [Options.FailOnHttpStatusCodes].
|
||||
// matches with one of the entries in [Options.FailOnHttpStatusCodes].
|
||||
ErrInvalidHttpStatusCode = errors.New("invalid HTTP status code")
|
||||
|
||||
// ErrInvalidResourceHttpStatusCode happens when the status code from one
|
||||
// or more resources matches with one of the entries in
|
||||
// [Options.FailOnResourceHttpStatusCodes].
|
||||
ErrInvalidResourceHttpStatusCode = errors.New("invalid resource HTTP status code")
|
||||
|
||||
// ErrConsoleExceptions happens when there are exceptions in the Chromium
|
||||
// console. It also happens only if the [Options.FailOnConsoleExceptions]
|
||||
// is set to true.
|
||||
ErrConsoleExceptions = errors.New("console exceptions")
|
||||
|
||||
// ErrLoadingFailed happens when the main page failed to load.
|
||||
ErrLoadingFailed = errors.New("loading failed")
|
||||
|
||||
// ErrResourceLoadingFailed happens when one or more resources failed to load.
|
||||
ErrResourceLoadingFailed = errors.New("resource loading failed")
|
||||
|
||||
// PDF specific.
|
||||
|
||||
// ErrOmitBackgroundWithoutPrintBackground happens if
|
||||
@@ -56,12 +68,12 @@ var (
|
||||
ErrInvalidPrinterSettings = errors.New("invalid printer settings")
|
||||
|
||||
// ErrPageRangesSyntaxError happens if the PdfOptions have an invalid page
|
||||
// ranges.
|
||||
// range.
|
||||
ErrPageRangesSyntaxError = errors.New("page ranges syntax error")
|
||||
)
|
||||
|
||||
// Chromium is a module which provides both an [Api] and routes for converting
|
||||
// HTML document to PDF.
|
||||
// Chromium is a module that provides both an [Api] and routes for converting
|
||||
// an HTML document to PDF.
|
||||
type Chromium struct {
|
||||
autoStart bool
|
||||
disableRoutes bool
|
||||
@@ -79,62 +91,71 @@ type Options struct {
|
||||
// "networkIdle" event, drastically improving the conversion speed. It may
|
||||
// not be suitable for all HTML documents, as some may not be fully
|
||||
// rendered until this event is fired.
|
||||
// Optional.
|
||||
SkipNetworkIdleEvent bool
|
||||
|
||||
// FailOnHttpStatusCodes sets if the conversion should fail if the status
|
||||
// code from the main page matches with one of its entries.
|
||||
// Optional.
|
||||
FailOnHttpStatusCodes []int64
|
||||
|
||||
// FailOnResourceHttpStatusCodes sets if the conversion should fail if the
|
||||
// status code from at least one resource matches with one if its entries.
|
||||
FailOnResourceHttpStatusCodes []int64
|
||||
|
||||
// FailOnResourceLoadingFailed sets if the conversion should fail like the
|
||||
// main page if Chromium fails to load at least one resource.
|
||||
FailOnResourceLoadingFailed bool
|
||||
|
||||
// FailOnConsoleExceptions sets if the conversion should fail if there are
|
||||
// exceptions in the Chromium console.
|
||||
// Optional.
|
||||
FailOnConsoleExceptions bool
|
||||
|
||||
// WaitDelay is the duration to wait when loading an HTML document before
|
||||
// converting it.
|
||||
// Optional.
|
||||
WaitDelay time.Duration
|
||||
|
||||
// WaitWindowStatus is the window.status value to wait for before
|
||||
// converting an HTML document.
|
||||
// Optional.
|
||||
WaitWindowStatus string
|
||||
|
||||
// WaitForExpression is the custom JavaScript expression to wait before
|
||||
// converting an HTML document until it returns true
|
||||
// Optional.
|
||||
WaitForExpression string
|
||||
|
||||
// ExtraHttpHeaders are the HTTP headers to send by Chromium while loading
|
||||
// the HTML document.
|
||||
// Optional.
|
||||
ExtraHttpHeaders map[string]string
|
||||
// Cookies are the cookies to put in the Chromium cookies' jar.
|
||||
Cookies []Cookie
|
||||
|
||||
// UserAgent overrides the default 'User-Agent' HTTP header.
|
||||
UserAgent string
|
||||
|
||||
// ExtraHttpHeaders are extra HTTP headers to send by Chromium while
|
||||
// loading the HTML document.
|
||||
ExtraHttpHeaders []ExtraHttpHeader
|
||||
|
||||
// EmulatedMediaType is the media type to emulate, either "screen" or
|
||||
// "print".
|
||||
// Optional.
|
||||
EmulatedMediaType string
|
||||
|
||||
// OmitBackground hides default white background and allows generating PDFs
|
||||
// with transparency.
|
||||
// Optional.
|
||||
// OmitBackground hides the default white background and allows generating
|
||||
// PDFs with transparency.
|
||||
OmitBackground bool
|
||||
}
|
||||
|
||||
// DefaultOptions returns the default values for Options.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
SkipNetworkIdleEvent: false,
|
||||
FailOnHttpStatusCodes: []int64{499, 599},
|
||||
FailOnConsoleExceptions: false,
|
||||
WaitDelay: 0,
|
||||
WaitWindowStatus: "",
|
||||
WaitForExpression: "",
|
||||
ExtraHttpHeaders: nil,
|
||||
EmulatedMediaType: "",
|
||||
OmitBackground: false,
|
||||
SkipNetworkIdleEvent: true,
|
||||
FailOnHttpStatusCodes: []int64{499, 599},
|
||||
FailOnResourceHttpStatusCodes: nil,
|
||||
FailOnResourceLoadingFailed: false,
|
||||
FailOnConsoleExceptions: false,
|
||||
WaitDelay: 0,
|
||||
WaitWindowStatus: "",
|
||||
WaitForExpression: "",
|
||||
Cookies: nil,
|
||||
UserAgent: "",
|
||||
ExtraHttpHeaders: nil,
|
||||
EmulatedMediaType: "",
|
||||
OmitBackground: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,48 +164,42 @@ type PdfOptions struct {
|
||||
Options
|
||||
|
||||
// Landscape sets the paper orientation.
|
||||
// Optional.
|
||||
Landscape bool
|
||||
|
||||
// PrintBackground prints the background graphics.
|
||||
// Optional.
|
||||
PrintBackground bool
|
||||
|
||||
// Scale is the scale of the page rendering.
|
||||
// Optional.
|
||||
Scale float64
|
||||
|
||||
// SinglePage defines whether to print the entire content in one single
|
||||
// page.
|
||||
SinglePage bool
|
||||
|
||||
// PaperWidth is the paper width, in inches.
|
||||
// Optional.
|
||||
PaperWidth float64
|
||||
|
||||
// PaperHeight is the paper height, in inches.
|
||||
// Optional.
|
||||
PaperHeight float64
|
||||
|
||||
// MarginTop is the top margin, in inches.
|
||||
// Optional.
|
||||
MarginTop float64
|
||||
|
||||
// MarginBottom is the bottom margin, in inches.
|
||||
// Optional.
|
||||
MarginBottom float64
|
||||
|
||||
// MarginLeft is the left margin, in inches.
|
||||
// Optional.
|
||||
MarginLeft float64
|
||||
|
||||
// MarginRight is the right margin, in inches.
|
||||
// Optional.
|
||||
MarginRight float64
|
||||
|
||||
// Page ranges to print, e.g., '1-5, 8, 11-13'. Empty means all pages.
|
||||
// Optional.
|
||||
PageRanges string
|
||||
|
||||
// HeaderTemplate is the HTML template of the header. It should be valid
|
||||
// HTML markup with following classes used to inject printing values into
|
||||
// them:
|
||||
// HeaderTemplate is the HTML template of the header. It should be a valid
|
||||
// HTML markup with the following classes used to inject printing values
|
||||
// into them:
|
||||
// - date: formatted print date
|
||||
// - title: document title
|
||||
// - url: document location
|
||||
@@ -192,37 +207,45 @@ type PdfOptions struct {
|
||||
// - totalPages: total pages in the document
|
||||
// For example, <span class=title></span> would generate span containing
|
||||
// the title.
|
||||
// Optional.
|
||||
HeaderTemplate string
|
||||
|
||||
// FooterTemplate is the HTML template of the footer. It should use the
|
||||
// same format as the HeaderTemplate.
|
||||
// Optional.
|
||||
FooterTemplate string
|
||||
|
||||
// PreferCssPageSize defines whether to prefer page size as defined by CSS.
|
||||
// If false, the content will be scaled to fit the paper size.
|
||||
// Optional.
|
||||
PreferCssPageSize bool
|
||||
|
||||
// GenerateDocumentOutline defines whether the document outline should be
|
||||
// embedded into the PDF.
|
||||
GenerateDocumentOutline bool
|
||||
|
||||
// GenerateTaggedPdf defines whether to generate tagged (accessible)
|
||||
// PDF.
|
||||
GenerateTaggedPdf bool
|
||||
}
|
||||
|
||||
// DefaultPdfOptions returns the default values for PdfOptions.
|
||||
func DefaultPdfOptions() PdfOptions {
|
||||
return PdfOptions{
|
||||
Options: DefaultOptions(),
|
||||
Landscape: false,
|
||||
PrintBackground: false,
|
||||
Scale: 1.0,
|
||||
PaperWidth: 8.5,
|
||||
PaperHeight: 11,
|
||||
MarginTop: 0.39,
|
||||
MarginBottom: 0.39,
|
||||
MarginLeft: 0.39,
|
||||
MarginRight: 0.39,
|
||||
PageRanges: "",
|
||||
HeaderTemplate: "<html><head></head><body></body></html>",
|
||||
FooterTemplate: "<html><head></head><body></body></html>",
|
||||
PreferCssPageSize: false,
|
||||
Options: DefaultOptions(),
|
||||
Landscape: false,
|
||||
PrintBackground: false,
|
||||
Scale: 1.0,
|
||||
SinglePage: false,
|
||||
PaperWidth: 8.5,
|
||||
PaperHeight: 11,
|
||||
MarginTop: 0.39,
|
||||
MarginBottom: 0.39,
|
||||
MarginLeft: 0.39,
|
||||
MarginRight: 0.39,
|
||||
PageRanges: "",
|
||||
HeaderTemplate: "<html><head></head><body></body></html>",
|
||||
FooterTemplate: "<html><head></head><body></body></html>",
|
||||
PreferCssPageSize: false,
|
||||
GenerateDocumentOutline: false,
|
||||
GenerateTaggedPdf: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,18 +254,25 @@ func DefaultPdfOptions() PdfOptions {
|
||||
type ScreenshotOptions struct {
|
||||
Options
|
||||
|
||||
// Width is the device screen width in pixels.
|
||||
Width int
|
||||
|
||||
// Height is the device screen height in pixels.
|
||||
Height int
|
||||
|
||||
// Clip defines whether to clip the screenshot according to the device
|
||||
// dimensions.
|
||||
Clip bool
|
||||
|
||||
// Format is the image compression format, either "png" or "jpeg" or
|
||||
// "webp".
|
||||
// Optional.
|
||||
Format string
|
||||
|
||||
// Quality is the compression quality from range [0..100] (jpeg only).
|
||||
// Optional.
|
||||
Quality int
|
||||
|
||||
// OptimizeForSpeed defines whether to optimize image encoding for speed,
|
||||
// not for resulting size.
|
||||
// Optional.
|
||||
OptimizeForSpeed bool
|
||||
}
|
||||
|
||||
@@ -250,19 +280,70 @@ type ScreenshotOptions struct {
|
||||
func DefaultScreenshotOptions() ScreenshotOptions {
|
||||
return ScreenshotOptions{
|
||||
Options: DefaultOptions(),
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
Clip: false,
|
||||
Format: "png",
|
||||
Quality: 100,
|
||||
OptimizeForSpeed: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Cookie gathers the available entries for setting a cookie in the Chromium
|
||||
// cookies' jar.
|
||||
type Cookie struct {
|
||||
// Name is the cookie name.
|
||||
// Required.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Value is the cookie value.
|
||||
// Required.
|
||||
Value string `json:"value"`
|
||||
|
||||
// Domain is the cookie domain.
|
||||
// Required.
|
||||
Domain string `json:"domain"`
|
||||
|
||||
// Path is the cookie path.
|
||||
// Optional.
|
||||
Path string `json:"path,omitempty"`
|
||||
|
||||
// Secure sets the cookie secure if true.
|
||||
// Optional.
|
||||
Secure bool `json:"secure,omitempty"`
|
||||
|
||||
// HttpOnly sets the cookie as HTTP-only if true.
|
||||
// Optional.
|
||||
HttpOnly bool `json:"httpOnly,omitempty"`
|
||||
|
||||
// SameSite is cookie 'Same-Site' status.
|
||||
// Optional.
|
||||
SameSite network.CookieSameSite `json:"sameSite,omitempty"`
|
||||
}
|
||||
|
||||
// ExtraHttpHeader are extra HTTP headers to send by Chromium.
|
||||
type ExtraHttpHeader struct {
|
||||
// Name is the header name.
|
||||
// Required.
|
||||
Name string
|
||||
|
||||
// Value is the header value.
|
||||
// Required.
|
||||
Value string
|
||||
|
||||
// Scope is the header scope. If nil, the header will be applied to ALL
|
||||
// requests from the page.
|
||||
// Optional.
|
||||
Scope *regexp2.Regexp
|
||||
}
|
||||
|
||||
// Api helps to interact with Chromium for converting HTML documents to PDF.
|
||||
type Api interface {
|
||||
Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
|
||||
Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
}
|
||||
|
||||
// Provider is a module interface which exposes a method for creating an [Api]
|
||||
// Provider is a module interface that exposes a method for creating an [Api]
|
||||
// for other modules.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
@@ -279,7 +360,8 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
||||
ID: "chromium",
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
|
||||
fs.Int64("chromium-restart-after", 0, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
|
||||
fs.Int64("chromium-restart-after", 10, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
|
||||
fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature")
|
||||
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
|
||||
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
|
||||
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
|
||||
@@ -290,7 +372,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
|
||||
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
|
||||
fs.String("chromium-allow-list", "", "Set the allowed URLs for Chromium using a regular expression")
|
||||
fs.String("chromium-deny-list", "^file:///[^tmp].*", "Set the denied URLs for Chromium using a regular expression")
|
||||
fs.String("chromium-deny-list", `^file:(?!//\/tmp/).*`, "Set the denied URLs for Chromium using a regular expression")
|
||||
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
||||
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||
@@ -344,7 +426,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
|
||||
// Process.
|
||||
mod.browser = newChromiumBrowser(mod.args)
|
||||
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"))
|
||||
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"))
|
||||
|
||||
// PDF Engine.
|
||||
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
||||
@@ -396,8 +478,8 @@ func (mod *Chromium) StartupMessage() string {
|
||||
|
||||
// Stop stops the current browser instance.
|
||||
func (mod *Chromium) Stop(ctx context.Context) error {
|
||||
// Block until the context is done so that other module may gracefully stop
|
||||
// before we do a shutdown.
|
||||
// Block until the context is done so that another module may gracefully
|
||||
// stop before we do a shutdown.
|
||||
mod.logger.Debug("wait for the end of grace duration")
|
||||
|
||||
<-ctx.Done()
|
||||
@@ -410,6 +492,23 @@ func (mod *Chromium) Stop(ctx context.Context) error {
|
||||
return fmt.Errorf("stop Chromium: %w", err)
|
||||
}
|
||||
|
||||
// Debug returns additional debug data.
|
||||
func (mod *Chromium) Debug() map[string]interface{} {
|
||||
debug := make(map[string]interface{})
|
||||
|
||||
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
debug["version"] = err.Error()
|
||||
return debug
|
||||
}
|
||||
|
||||
debug["version"] = strings.TrimSpace(string(output))
|
||||
return debug
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
func (mod *Chromium) Metrics() ([]gotenberg.Metric, error) {
|
||||
return []gotenberg.Metric{
|
||||
@@ -519,6 +618,7 @@ var (
|
||||
_ gotenberg.Provisioner = (*Chromium)(nil)
|
||||
_ gotenberg.Validator = (*Chromium)(nil)
|
||||
_ gotenberg.App = (*Chromium)(nil)
|
||||
_ gotenberg.Debuggable = (*Chromium)(nil)
|
||||
_ gotenberg.MetricsProvider = (*Chromium)(nil)
|
||||
_ api.HealthChecker = (*Chromium)(nil)
|
||||
_ api.Router = (*Chromium)(nil)
|
||||
|
||||
@@ -1,571 +0,0 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestDefaultOptions(t *testing.T) {
|
||||
actual := DefaultPdfOptions()
|
||||
notExpect := PdfOptions{}
|
||||
|
||||
if reflect.DeepEqual(actual, notExpect) {
|
||||
t.Errorf("expected %v and got identical %v", actual, notExpect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Descriptor(t *testing.T) {
|
||||
descriptor := new(Chromium).Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(Chromium))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Provision(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *gotenberg.Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "no logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Chromium).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no logger from logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Chromium).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no PDF engine provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return zap.NewNop(), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Chromium).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no PDF engine from PDF engine provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
gotenberg.PdfEngineProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return zap.NewNop(), nil
|
||||
}
|
||||
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Chromium).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "provision success",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
gotenberg.PdfEngineProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return zap.NewNop(), nil
|
||||
}
|
||||
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
|
||||
return new(gotenberg.PdfEngineMock), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Chromium).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
err := mod.Provision(tc.ctx)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Validate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
binPath string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "empty bin path",
|
||||
binPath: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "bin path does not exist",
|
||||
binPath: "/foo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "validate success",
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
mod.args = browserArguments{
|
||||
binPath: tc.binPath,
|
||||
}
|
||||
err := mod.Validate()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Start(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
autoStart bool
|
||||
supervisor *gotenberg.ProcessSupervisorMock
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "no auto-start",
|
||||
autoStart: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start success",
|
||||
autoStart: true,
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start failed",
|
||||
autoStart: true,
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
|
||||
return errors.New("foo")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
mod.autoStart = tc.autoStart
|
||||
mod.supervisor = tc.supervisor
|
||||
|
||||
err := mod.Start()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_StartupMessage(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
|
||||
mod.autoStart = true
|
||||
autoStartMsg := mod.StartupMessage()
|
||||
|
||||
mod.autoStart = false
|
||||
noAutoStartMsg := mod.StartupMessage()
|
||||
|
||||
if autoStartMsg == noAutoStartMsg {
|
||||
t.Errorf("expected differrent startup messages based on auto start, but got '%s'", autoStartMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Stop(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor *gotenberg.ProcessSupervisorMock
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "stop success",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "stop failed",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
|
||||
return errors.New("foo")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
mod.logger = zap.NewNop()
|
||||
mod.supervisor = tc.supervisor
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second)
|
||||
cancel()
|
||||
|
||||
err := mod.Stop(ctx)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Metrics(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
mod.supervisor = &gotenberg.ProcessSupervisorMock{
|
||||
ReqQueueSizeMock: func() int64 {
|
||||
return 10
|
||||
},
|
||||
RestartsCountMock: func() int64 {
|
||||
return 0
|
||||
},
|
||||
}
|
||||
|
||||
metrics, err := mod.Metrics()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if len(metrics) != 2 {
|
||||
t.Fatalf("expected %d metrics, but got %d", 2, len(metrics))
|
||||
}
|
||||
|
||||
actual := metrics[0].Read()
|
||||
if actual != float64(10) {
|
||||
t.Errorf("expected %f for chromium_requests_queue_size, but got %f", float64(10), actual)
|
||||
}
|
||||
|
||||
actual = metrics[1].Read()
|
||||
if actual != float64(0) {
|
||||
t.Errorf("expected %f for chromium_restarts_count, but got %f", float64(0), actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Checks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
expectAvailabilityStatus health.AvailabilityStatus
|
||||
}{
|
||||
{
|
||||
scenario: "healthy module",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
|
||||
return true
|
||||
}},
|
||||
expectAvailabilityStatus: health.StatusUp,
|
||||
},
|
||||
{
|
||||
scenario: "unhealthy module",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
|
||||
return false
|
||||
}},
|
||||
expectAvailabilityStatus: health.StatusDown,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
mod.supervisor = tc.supervisor
|
||||
|
||||
checks, err := mod.Checks()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
checker := health.NewChecker(checks...)
|
||||
result := checker.Check(context.Background())
|
||||
|
||||
if result.Status != tc.expectAvailabilityStatus {
|
||||
t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Ready(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
autoStart bool
|
||||
startTimeout time.Duration
|
||||
browser browser
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "no auto-start",
|
||||
autoStart: false,
|
||||
startTimeout: time.Duration(30) * time.Second,
|
||||
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||
return false
|
||||
}}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start: context done",
|
||||
autoStart: true,
|
||||
startTimeout: time.Duration(200) * time.Millisecond,
|
||||
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||
return false
|
||||
}}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start success",
|
||||
autoStart: true,
|
||||
startTimeout: time.Duration(30) * time.Second,
|
||||
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||
return true
|
||||
}}},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
mod.autoStart = tc.autoStart
|
||||
mod.args = browserArguments{wsUrlReadTimeout: tc.startTimeout}
|
||||
mod.browser = tc.browser
|
||||
|
||||
err := mod.Ready()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Chromium(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
|
||||
_, err := mod.Chromium()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Routes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
expectRoutes int
|
||||
disableRoutes bool
|
||||
}{
|
||||
{
|
||||
scenario: "routes not disabled",
|
||||
expectRoutes: 6,
|
||||
disableRoutes: false,
|
||||
},
|
||||
{
|
||||
scenario: "routes disabled",
|
||||
expectRoutes: 0,
|
||||
disableRoutes: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
mod.disableRoutes = tc.disableRoutes
|
||||
|
||||
routes, err := mod.Routes()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectRoutes != len(routes) {
|
||||
t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Pdf(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
browser browser
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "PDF task success",
|
||||
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "PDF task error",
|
||||
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return errors.New("PDF task error")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
return task()
|
||||
}}
|
||||
mod.browser = tc.browser
|
||||
|
||||
err := mod.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Screenshot(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
browser browser
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "Screenshot task success",
|
||||
browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "Screenshot task error",
|
||||
browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return errors.New("screenshot task error")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
return task()
|
||||
}}
|
||||
mod.browser = tc.browser
|
||||
|
||||
err := mod.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestDebugLogger_Write(t *testing.T) {
|
||||
actual, err := (&debugLogger{logger: zap.NewNop()}).Write([]byte("foo"))
|
||||
expected := len([]byte("foo"))
|
||||
|
||||
if actual != expected {
|
||||
t.Errorf("expected %d but got %d", expected, actual)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("expected not error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugLogger_Printf(t *testing.T) {
|
||||
(&debugLogger{logger: zap.NewNop()}).Printf("%s", "foo")
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package chromium
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
@@ -13,14 +13,30 @@ import (
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/dlclark/regexp2"
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
type eventRequestPausedOptions struct {
|
||||
allowList, denyList *regexp2.Regexp
|
||||
extraHttpHeaders []ExtraHttpHeader
|
||||
}
|
||||
|
||||
// listenForEventRequestPaused listens for requests to check if they are
|
||||
// allowed or not.
|
||||
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp.Regexp, denyList *regexp.Regexp) {
|
||||
// allowed or not. It also set the extra HTTP headers, if any.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||
// TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now).
|
||||
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, options eventRequestPausedOptions) {
|
||||
if len(options.extraHttpHeaders) == 0 {
|
||||
logger.Debug("no extra HTTP headers")
|
||||
} else {
|
||||
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
|
||||
}
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch e := ev.(type) {
|
||||
case *fetch.EventRequestPaused:
|
||||
@@ -28,47 +44,129 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
|
||||
logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
|
||||
allow := true
|
||||
|
||||
if !allowList.MatchString(e.Request.URL) {
|
||||
logger.Warn(fmt.Sprintf("'%s' does not match the expression from the allowed list", e.Request.URL))
|
||||
allow = false
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
logger.Error("context has no deadline, cannot filter URL")
|
||||
return
|
||||
}
|
||||
|
||||
if denyList.String() != "" && denyList.MatchString(e.Request.URL) {
|
||||
logger.Warn(fmt.Sprintf("'%s' matches the expression from the denied list", e.Request.URL))
|
||||
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
|
||||
if err != nil {
|
||||
logger.Warn(err.Error())
|
||||
allow = false
|
||||
}
|
||||
|
||||
cctx := chromedp.FromContext(ctx)
|
||||
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
|
||||
|
||||
if allow {
|
||||
req := fetch.ContinueRequest(e.RequestID)
|
||||
err := req.Do(executorCtx)
|
||||
if !allow {
|
||||
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
|
||||
err = req.Do(executorCtx)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("continue request: %s", err))
|
||||
logger.Error(fmt.Sprintf("fail request: %s", err))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
|
||||
err := req.Do(executorCtx)
|
||||
req := fetch.ContinueRequest(e.RequestID)
|
||||
|
||||
var extraHttpHeadersToSet []ExtraHttpHeader
|
||||
if len(options.extraHttpHeaders) > 0 {
|
||||
// The user wants to set extra HTTP headers.
|
||||
|
||||
// First, we have to check if at least one header has to be
|
||||
// set for the current request.
|
||||
for _, header := range options.extraHttpHeaders {
|
||||
if header.Scope == nil {
|
||||
// Non-scoped header.
|
||||
logger.Debug(fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
|
||||
continue
|
||||
}
|
||||
|
||||
ok, err := header.Scope.MatchString(e.Request.URL)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
|
||||
} else if ok {
|
||||
logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
|
||||
} else {
|
||||
logger.Debug(fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(extraHttpHeadersToSet) > 0 {
|
||||
logger.Debug(fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet))
|
||||
|
||||
originalHeaders := e.Request.Headers
|
||||
headers := make(map[string]string)
|
||||
|
||||
for key, value := range originalHeaders {
|
||||
strValue, ok := value.(string)
|
||||
if ok {
|
||||
headers[key] = strValue
|
||||
} else {
|
||||
logger.Error(fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL))
|
||||
}
|
||||
}
|
||||
|
||||
var headersEntries []*fetch.HeaderEntry
|
||||
for key, value := range headers {
|
||||
headersEntries = append(headersEntries, &fetch.HeaderEntry{
|
||||
Name: key,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
for _, header := range extraHttpHeadersToSet {
|
||||
headersEntries = append(headersEntries, &fetch.HeaderEntry{
|
||||
Name: header.Name,
|
||||
Value: header.Value,
|
||||
})
|
||||
}
|
||||
|
||||
req.Headers = headersEntries
|
||||
}
|
||||
|
||||
err = req.Do(executorCtx)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("fail request: %s", err))
|
||||
logger.Error(fmt.Sprintf("continue request: %s", err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// listenForEventResponseReceived listens for an invalid HTTP status code is
|
||||
// returned by the main page.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/613.
|
||||
func listenForEventResponseReceived(ctx context.Context, logger *zap.Logger, url string, failOnHttpStatusCodes []int64, invalidHttpStatusCode *error, invalidHttpStatusCodeMu *sync.RWMutex) {
|
||||
type eventResponseReceivedOptions struct {
|
||||
mainPageUrl string
|
||||
failOnHttpStatusCodes []int64
|
||||
invalidHttpStatusCode *error
|
||||
invalidHttpStatusCodeMu *sync.RWMutex
|
||||
failOnResourceOnHttpStatusCode []int64
|
||||
invalidResourceHttpStatusCode *error
|
||||
invalidResourceHttpStatusCodeMu *sync.RWMutex
|
||||
}
|
||||
|
||||
// listenForEventResponseReceived listens for an invalid HTTP status code
|
||||
// returned by the main page or by one or more resources.
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/613.
|
||||
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
func listenForEventResponseReceived(
|
||||
ctx context.Context,
|
||||
logger *zap.Logger,
|
||||
options eventResponseReceivedOptions,
|
||||
) {
|
||||
for _, code := range []int64{199, 299, 399, 499, 599} {
|
||||
if slices.Contains(failOnHttpStatusCodes, code) {
|
||||
if slices.Contains(options.failOnHttpStatusCodes, code) {
|
||||
for i := code - 99; i <= code; i++ {
|
||||
failOnHttpStatusCodes = append(failOnHttpStatusCodes, i)
|
||||
options.failOnHttpStatusCodes = append(options.failOnHttpStatusCodes, i)
|
||||
}
|
||||
}
|
||||
|
||||
if slices.Contains(options.failOnResourceOnHttpStatusCode, code) {
|
||||
for i := code - 99; i <= code; i++ {
|
||||
options.failOnResourceOnHttpStatusCode = append(options.failOnResourceOnHttpStatusCode, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,22 +174,99 @@ func listenForEventResponseReceived(ctx context.Context, logger *zap.Logger, url
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch ev := ev.(type) {
|
||||
case *network.EventResponseReceived:
|
||||
if ev.Response.URL != url {
|
||||
if ev.Response.URL == options.mainPageUrl {
|
||||
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
|
||||
|
||||
if slices.Contains(options.failOnHttpStatusCodes, ev.Response.Status) {
|
||||
options.invalidHttpStatusCodeMu.Lock()
|
||||
defer options.invalidHttpStatusCodeMu.Unlock()
|
||||
|
||||
*options.invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
|
||||
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
|
||||
|
||||
if slices.Contains(failOnHttpStatusCodes, ev.Response.Status) {
|
||||
invalidHttpStatusCodeMu.Lock()
|
||||
defer invalidHttpStatusCodeMu.Unlock()
|
||||
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
|
||||
options.invalidResourceHttpStatusCodeMu.Lock()
|
||||
defer options.invalidResourceHttpStatusCodeMu.Unlock()
|
||||
|
||||
*invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
|
||||
*options.invalidResourceHttpStatusCode = multierr.Append(
|
||||
*options.invalidResourceHttpStatusCode,
|
||||
fmt.Errorf("%s - %d: %s", ev.Response.URL, ev.Response.Status, http.StatusText(int(ev.Response.Status))),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type eventLoadingFailedOptions struct {
|
||||
loadingFailed *error
|
||||
loadingFailedMu *sync.RWMutex
|
||||
resourceLoadingFailed *error
|
||||
resourceLoadingFailedMu *sync.RWMutex
|
||||
}
|
||||
|
||||
// listenForEventLoadingFailed listens for an event indicating that the main
|
||||
// page or one or more resources failed to load.
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/913.
|
||||
// https://github.com/gotenberg/gotenberg/issues/959.
|
||||
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) {
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch ev := ev.(type) {
|
||||
case *network.EventLoadingFailed:
|
||||
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
|
||||
|
||||
// We are looking for common errors.
|
||||
// TODO: sufficient?
|
||||
errors := []string{
|
||||
"net::ERR_CONNECTION_CLOSED",
|
||||
"net::ERR_CONNECTION_RESET",
|
||||
"net::ERR_CONNECTION_REFUSED",
|
||||
"net::ERR_CONNECTION_ABORTED",
|
||||
"net::ERR_CONNECTION_FAILED",
|
||||
"net::ERR_NAME_NOT_RESOLVED",
|
||||
"net::ERR_INTERNET_DISCONNECTED",
|
||||
"net::ERR_ADDRESS_UNREACHABLE",
|
||||
"net::ERR_BLOCKED_BY_CLIENT",
|
||||
"net::ERR_BLOCKED_BY_RESPONSE",
|
||||
"net::ERR_FILE_NOT_FOUND",
|
||||
}
|
||||
if !slices.Contains(errors, ev.ErrorText) {
|
||||
logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
|
||||
return
|
||||
}
|
||||
|
||||
if ev.Type == network.ResourceTypeDocument {
|
||||
// Supposition: except iframe, an event loading failed with a
|
||||
// resource type Document is about the main page.
|
||||
logger.Debug("event EventLoadingFailed fired for main page")
|
||||
|
||||
options.loadingFailedMu.Lock()
|
||||
defer options.loadingFailedMu.Unlock()
|
||||
|
||||
*options.loadingFailed = fmt.Errorf("%s", ev.ErrorText)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("event EventLoadingFailed fired for a resource")
|
||||
|
||||
options.resourceLoadingFailedMu.Lock()
|
||||
defer options.resourceLoadingFailedMu.Unlock()
|
||||
|
||||
*options.resourceLoadingFailed = multierr.Append(
|
||||
*options.resourceLoadingFailed,
|
||||
fmt.Errorf("resource %s: %s", ev.Type, ev.ErrorText),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// listenForEventExceptionThrown listens for exceptions in the console and
|
||||
// appends those exceptions to the given error pointer.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestApiMock(t *testing.T) {
|
||||
mock := &ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return nil
|
||||
},
|
||||
ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ApiMock.Screenshot, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserMock(t *testing.T) {
|
||||
mock := &browserMock{
|
||||
pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return nil
|
||||
},
|
||||
screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from browserMock.pdf, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from browserMock.screenshot, but got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/russross/blackfriday/v2"
|
||||
@@ -20,23 +21,28 @@ import (
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
||||
)
|
||||
|
||||
// FormDataChromiumOptions creates [Options] from the form data. Fallback to
|
||||
// default value if the considered key is not present.
|
||||
// the default value if the considered key is not present.
|
||||
func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
defaultOptions := DefaultOptions()
|
||||
|
||||
var (
|
||||
skipNetworkIdleEvent bool
|
||||
failOnHttpStatusCodes []int64
|
||||
failOnConsoleExceptions bool
|
||||
waitDelay time.Duration
|
||||
waitWindowStatus string
|
||||
waitForExpression string
|
||||
extraHttpHeaders map[string]string
|
||||
emulatedMediaType string
|
||||
omitBackground bool
|
||||
skipNetworkIdleEvent bool
|
||||
failOnHttpStatusCodes []int64
|
||||
failOnResourceHttpStatusCodes []int64
|
||||
failOnResourceLoadingFailed bool
|
||||
failOnConsoleExceptions bool
|
||||
waitDelay time.Duration
|
||||
waitWindowStatus string
|
||||
waitForExpression string
|
||||
cookies []Cookie
|
||||
userAgent string
|
||||
extraHttpHeaders []ExtraHttpHeader
|
||||
emulatedMediaType string
|
||||
omitBackground bool
|
||||
)
|
||||
|
||||
form := ctx.FormData().
|
||||
@@ -54,22 +60,103 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
|
||||
return nil
|
||||
}).
|
||||
Custom("failOnResourceHttpStatusCodes", func(value string) error {
|
||||
if value == "" {
|
||||
failOnResourceHttpStatusCodes = defaultOptions.FailOnResourceHttpStatusCodes
|
||||
return nil
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(value), &failOnResourceHttpStatusCodes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal failOnResourceHttpStatusCodes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}).
|
||||
Bool("failOnResourceLoadingFailed", &failOnResourceLoadingFailed, defaultOptions.FailOnResourceLoadingFailed).
|
||||
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
|
||||
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
|
||||
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
|
||||
String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression).
|
||||
Custom("cookies", func(value string) error {
|
||||
if value == "" {
|
||||
cookies = defaultOptions.Cookies
|
||||
return nil
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(value), &cookies)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal cookies: %w", err)
|
||||
}
|
||||
|
||||
for i, cookie := range cookies {
|
||||
if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" || strings.TrimSpace(cookie.Domain) == "" {
|
||||
err = multierr.Append(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}).
|
||||
String("userAgent", &userAgent, defaultOptions.UserAgent).
|
||||
Custom("extraHttpHeaders", func(value string) error {
|
||||
if value == "" {
|
||||
extraHttpHeaders = defaultOptions.ExtraHttpHeaders
|
||||
return nil
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(value), &extraHttpHeaders)
|
||||
var headers map[string]string
|
||||
err := json.Unmarshal([]byte(value), &headers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
for k, v := range headers {
|
||||
var scope string
|
||||
var valueTokens []string
|
||||
var invalidScopeToken bool
|
||||
|
||||
tokens := strings.Split(v, ";")
|
||||
for _, token := range tokens {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(token)), "scope") {
|
||||
tokenNoSpaces := strings.Join(strings.Fields(token), "")
|
||||
parts := strings.SplitN(tokenNoSpaces, "=", 2)
|
||||
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "scope" && parts[1] != "" {
|
||||
scope = parts[1]
|
||||
} else {
|
||||
err = multierr.Append(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
|
||||
invalidScopeToken = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if token != "" {
|
||||
valueTokens = append(valueTokens, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if invalidScopeToken {
|
||||
continue
|
||||
}
|
||||
|
||||
var scopeRegexp *regexp2.Regexp
|
||||
if len(scope) > 0 {
|
||||
p, errCompile := regexp2.Compile(scope, 0)
|
||||
if errCompile != nil {
|
||||
err = multierr.Append(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
||||
continue
|
||||
}
|
||||
scopeRegexp = p
|
||||
}
|
||||
|
||||
extraHttpHeaders = append(extraHttpHeaders, ExtraHttpHeader{
|
||||
Name: k,
|
||||
Value: strings.Join(valueTokens, "; "),
|
||||
Scope: scopeRegexp,
|
||||
})
|
||||
}
|
||||
|
||||
return err
|
||||
}).
|
||||
Custom("emulatedMediaType", func(value string) error {
|
||||
if value == "" {
|
||||
@@ -78,7 +165,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
}
|
||||
|
||||
if value != "screen" && value != "print" {
|
||||
return fmt.Errorf("wrong value, expected either 'screen', 'print' or empty")
|
||||
return errors.New("wrong value, expected either 'screen', 'print' or empty")
|
||||
}
|
||||
|
||||
emulatedMediaType = value
|
||||
@@ -88,83 +175,100 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
|
||||
|
||||
options := Options{
|
||||
SkipNetworkIdleEvent: skipNetworkIdleEvent,
|
||||
FailOnHttpStatusCodes: failOnHttpStatusCodes,
|
||||
FailOnConsoleExceptions: failOnConsoleExceptions,
|
||||
WaitDelay: waitDelay,
|
||||
WaitWindowStatus: waitWindowStatus,
|
||||
WaitForExpression: waitForExpression,
|
||||
ExtraHttpHeaders: extraHttpHeaders,
|
||||
EmulatedMediaType: emulatedMediaType,
|
||||
OmitBackground: omitBackground,
|
||||
SkipNetworkIdleEvent: skipNetworkIdleEvent,
|
||||
FailOnHttpStatusCodes: failOnHttpStatusCodes,
|
||||
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
|
||||
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
|
||||
FailOnConsoleExceptions: failOnConsoleExceptions,
|
||||
WaitDelay: waitDelay,
|
||||
WaitWindowStatus: waitWindowStatus,
|
||||
WaitForExpression: waitForExpression,
|
||||
Cookies: cookies,
|
||||
UserAgent: userAgent,
|
||||
ExtraHttpHeaders: extraHttpHeaders,
|
||||
EmulatedMediaType: emulatedMediaType,
|
||||
OmitBackground: omitBackground,
|
||||
}
|
||||
|
||||
return form, options
|
||||
}
|
||||
|
||||
// FormDataChromiumPdfOptions creates [PdfOptions] from the form data. Fallback to
|
||||
// default value if the considered key is not present.
|
||||
// the default value if the considered key is not present.
|
||||
func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
|
||||
form, options := FormDataChromiumOptions(ctx)
|
||||
defaultPdfOptions := DefaultPdfOptions()
|
||||
|
||||
var (
|
||||
landscape, printBackground bool
|
||||
landscape, printBackground, singlePage bool
|
||||
scale, paperWidth, paperHeight float64
|
||||
marginTop, marginBottom, marginLeft, marginRight float64
|
||||
pageRanges string
|
||||
headerTemplate, footerTemplate string
|
||||
preferCssPageSize bool
|
||||
generateDocumentOutline bool
|
||||
generateTaggedPdf bool
|
||||
)
|
||||
|
||||
form.
|
||||
Bool("landscape", &landscape, defaultPdfOptions.Landscape).
|
||||
Bool("printBackground", &printBackground, defaultPdfOptions.PrintBackground).
|
||||
Float64("scale", &scale, defaultPdfOptions.Scale).
|
||||
Float64("paperWidth", &paperWidth, defaultPdfOptions.PaperWidth).
|
||||
Float64("paperHeight", &paperHeight, defaultPdfOptions.PaperHeight).
|
||||
Float64("marginTop", &marginTop, defaultPdfOptions.MarginTop).
|
||||
Float64("marginBottom", &marginBottom, defaultPdfOptions.MarginBottom).
|
||||
Float64("marginLeft", &marginLeft, defaultPdfOptions.MarginLeft).
|
||||
Float64("marginRight", &marginRight, defaultPdfOptions.MarginRight).
|
||||
Bool("singlePage", &singlePage, defaultPdfOptions.SinglePage).
|
||||
Inches("paperWidth", &paperWidth, defaultPdfOptions.PaperWidth).
|
||||
Inches("paperHeight", &paperHeight, defaultPdfOptions.PaperHeight).
|
||||
Inches("marginTop", &marginTop, defaultPdfOptions.MarginTop).
|
||||
Inches("marginBottom", &marginBottom, defaultPdfOptions.MarginBottom).
|
||||
Inches("marginLeft", &marginLeft, defaultPdfOptions.MarginLeft).
|
||||
Inches("marginRight", &marginRight, defaultPdfOptions.MarginRight).
|
||||
String("nativePageRanges", &pageRanges, defaultPdfOptions.PageRanges).
|
||||
Content("header.html", &headerTemplate, defaultPdfOptions.HeaderTemplate).
|
||||
Content("footer.html", &footerTemplate, defaultPdfOptions.FooterTemplate).
|
||||
Bool("preferCssPageSize", &preferCssPageSize, defaultPdfOptions.PreferCssPageSize)
|
||||
Bool("preferCssPageSize", &preferCssPageSize, defaultPdfOptions.PreferCssPageSize).
|
||||
Bool("generateDocumentOutline", &generateDocumentOutline, defaultPdfOptions.GenerateDocumentOutline).
|
||||
Bool("generateTaggedPdf", &generateTaggedPdf, defaultPdfOptions.GenerateTaggedPdf)
|
||||
|
||||
pdfOptions := PdfOptions{
|
||||
Options: options,
|
||||
Landscape: landscape,
|
||||
PrintBackground: printBackground,
|
||||
Scale: scale,
|
||||
PaperWidth: paperWidth,
|
||||
PaperHeight: paperHeight,
|
||||
MarginTop: marginTop,
|
||||
MarginBottom: marginBottom,
|
||||
MarginLeft: marginLeft,
|
||||
MarginRight: marginRight,
|
||||
PageRanges: pageRanges,
|
||||
HeaderTemplate: headerTemplate,
|
||||
FooterTemplate: footerTemplate,
|
||||
PreferCssPageSize: preferCssPageSize,
|
||||
Options: options,
|
||||
Landscape: landscape,
|
||||
PrintBackground: printBackground,
|
||||
Scale: scale,
|
||||
SinglePage: singlePage,
|
||||
PaperWidth: paperWidth,
|
||||
PaperHeight: paperHeight,
|
||||
MarginTop: marginTop,
|
||||
MarginBottom: marginBottom,
|
||||
MarginLeft: marginLeft,
|
||||
MarginRight: marginRight,
|
||||
PageRanges: pageRanges,
|
||||
HeaderTemplate: headerTemplate,
|
||||
FooterTemplate: footerTemplate,
|
||||
PreferCssPageSize: preferCssPageSize,
|
||||
GenerateDocumentOutline: generateDocumentOutline,
|
||||
GenerateTaggedPdf: generateTaggedPdf,
|
||||
}
|
||||
|
||||
return form, pdfOptions
|
||||
}
|
||||
|
||||
// FormDataChromiumScreenshotOptions creates [ScreenshotOptions] from the form
|
||||
// data. Fallback to default value if the considered key is not present.
|
||||
// data. Fallback to the default value if the considered key is not present.
|
||||
func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, ScreenshotOptions) {
|
||||
form, options := FormDataChromiumOptions(ctx)
|
||||
defaultScreenshotOptions := DefaultScreenshotOptions()
|
||||
|
||||
var (
|
||||
width, height int
|
||||
clip bool
|
||||
format string
|
||||
quality int
|
||||
optimizeForSpeed bool
|
||||
)
|
||||
|
||||
form.
|
||||
Int("width", &width, defaultScreenshotOptions.Width).
|
||||
Int("height", &height, defaultScreenshotOptions.Height).
|
||||
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
||||
Custom("format", func(value string) error {
|
||||
if value == "" {
|
||||
format = defaultScreenshotOptions.Format
|
||||
@@ -205,6 +309,9 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
|
||||
screenshotOptions := ScreenshotOptions{
|
||||
Options: options,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Clip: clip,
|
||||
Format: format,
|
||||
Quality: quality,
|
||||
OptimizeForSpeed: optimizeForSpeed,
|
||||
@@ -213,24 +320,6 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
return form, screenshotOptions
|
||||
}
|
||||
|
||||
// FormDataChromiumPdfFormats creates [gotenberg.PdfFormats] from the form
|
||||
// data. Fallback to default value if the considered key is not present.
|
||||
func FormDataChromiumPdfFormats(form *api.FormData) gotenberg.PdfFormats {
|
||||
var (
|
||||
pdfa string
|
||||
pdfua bool
|
||||
)
|
||||
|
||||
form.
|
||||
String("pdfa", &pdfa, "").
|
||||
Bool("pdfua", &pdfua, false)
|
||||
|
||||
return gotenberg.PdfFormats{
|
||||
PdfA: pdfa,
|
||||
PdfUa: pdfua,
|
||||
}
|
||||
}
|
||||
|
||||
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
|
||||
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return api.Route{
|
||||
@@ -240,7 +329,9 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPdfOptions(ctx)
|
||||
pdfFormats := FormDataChromiumPdfFormats(form)
|
||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
|
||||
var url string
|
||||
err := form.
|
||||
@@ -250,7 +341,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||
}
|
||||
@@ -299,7 +390,9 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPdfOptions(ctx)
|
||||
pdfFormats := FormDataChromiumPdfFormats(form)
|
||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
|
||||
var inputPath string
|
||||
err := form.
|
||||
@@ -310,7 +403,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("file://%s", inputPath)
|
||||
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||
}
|
||||
@@ -360,7 +453,9 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPdfOptions(ctx)
|
||||
pdfFormats := FormDataChromiumPdfFormats(form)
|
||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
|
||||
var (
|
||||
inputPath string
|
||||
@@ -380,7 +475,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||
}
|
||||
@@ -391,7 +486,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
|
||||
// screenshotMarkdownRoute returns an [api.Route] which can take a screenshot
|
||||
// from markdown files.
|
||||
// from Markdown files.
|
||||
func screenshotMarkdownRoute(chromium Api) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
@@ -430,7 +525,7 @@ func screenshotMarkdownRoute(chromium Api) api.Route {
|
||||
}
|
||||
|
||||
func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) (string, error) {
|
||||
// We have to convert each markdown file referenced in the HTML
|
||||
// We have to convert each Markdown file referenced in the HTML
|
||||
// file to... HTML. Thanks to the "html/template" package, we are
|
||||
// able to provide the "toHTML" function which the user may call
|
||||
// directly inside the HTML file.
|
||||
@@ -504,15 +599,18 @@ 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, pdfFormats gotenberg.PdfFormats, options PdfOptions) error {
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}) error {
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||
filename := ctx.OutputFilename(outputPath)
|
||||
outputPath = ctx.GeneratePathFromFilename(filename)
|
||||
|
||||
err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options)
|
||||
err = handleChromiumError(err, url, options.Options)
|
||||
err = handleChromiumError(err, options.Options)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrOmitBackgroundWithoutPrintBackground) {
|
||||
return api.WrapError(
|
||||
err,
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
"omitBackground requires printBackground set to true",
|
||||
@@ -543,37 +641,39 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
}
|
||||
|
||||
// So far so good, the URL has been converted to PDF.
|
||||
// Now, let's check if the client want to convert the resulting PDF
|
||||
// to specific formats.
|
||||
zeroValued := gotenberg.PdfFormats{}
|
||||
if pdfFormats != zeroValued {
|
||||
convertInputPath := outputPath
|
||||
convertOutputPath := ctx.GeneratePath(".pdf")
|
||||
|
||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert PDF: %w", err)
|
||||
}
|
||||
|
||||
// Important: the output path is now the converted file.
|
||||
outputPath = convertOutputPath
|
||||
outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath})
|
||||
if err != nil {
|
||||
return fmt.Errorf("split PDF: %w", err)
|
||||
}
|
||||
|
||||
err = ctx.AddOutputPaths(outputPath)
|
||||
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output path: %w", err)
|
||||
return fmt.Errorf("convert PDF(s): %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write metadata: %w", err)
|
||||
}
|
||||
|
||||
zeroValuedSplitMode := gotenberg.SplitMode{}
|
||||
zeroValuedPdfFormats := gotenberg.PdfFormats{}
|
||||
if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats {
|
||||
// The PDF has been split and split parts have been converted to a
|
||||
// specific format. We want to keep the split naming.
|
||||
for i, convertOutputPath := range convertOutputPaths {
|
||||
err = ctx.Rename(convertOutputPath, outputPaths[i])
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename output path: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
outputPaths = convertOutputPaths
|
||||
}
|
||||
|
||||
err = ctx.AddOutputPaths(outputPaths...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output paths: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -584,7 +684,7 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
|
||||
outputPath := ctx.GeneratePath(ext)
|
||||
|
||||
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
|
||||
err = handleChromiumError(err, url, options.Options)
|
||||
err = handleChromiumError(err, options.Options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("screenshot: %w", err)
|
||||
}
|
||||
@@ -597,21 +697,11 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleChromiumError(err error, url string, options Options) error {
|
||||
func handleChromiumError(err error, options Options) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrUrlNotAuthorized) {
|
||||
return api.WrapError(
|
||||
err,
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusForbidden,
|
||||
fmt.Sprintf("'%s' does not match the authorized URLs", url),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrInvalidEvaluationExpression) {
|
||||
if options.WaitForExpression == "" {
|
||||
// We do not expect the 'waitWindowStatus' form field to return
|
||||
@@ -639,12 +729,42 @@ func handleChromiumError(err error, url string, options Options) error {
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrInvalidResourceHttpStatusCode) {
|
||||
return api.WrapError(
|
||||
err,
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusConflict,
|
||||
fmt.Sprintf("Invalid HTTP status code from resources:\n%s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidResourceHttpStatusCode.Error()), "")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrConsoleExceptions) {
|
||||
return api.WrapError(
|
||||
err,
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusConflict,
|
||||
fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
|
||||
fmt.Sprintf("Chromium console exceptions:\n%s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrLoadingFailed) {
|
||||
return api.WrapError(
|
||||
err,
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("Chromium returned %v", err),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrResourceLoadingFailed) {
|
||||
return api.WrapError(
|
||||
err,
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusConflict,
|
||||
fmt.Sprintf("Chromium failed to load resources: %v", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrResourceLoadingFailed.Error()), "")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ type streamReader struct {
|
||||
// Read a chunk of the stream.
|
||||
func (reader *streamReader) Read(p []byte) (n int, err error) {
|
||||
if reader.r != nil {
|
||||
// Continue reading from buffer.
|
||||
// Continue reading from the buffer.
|
||||
return reader.read(p)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package chromium
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
@@ -17,19 +18,39 @@ import (
|
||||
|
||||
func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
paperHeight := options.PaperHeight
|
||||
pageRanges := options.PageRanges
|
||||
|
||||
if options.SinglePage {
|
||||
logger.Debug("single page PDF")
|
||||
|
||||
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get layout metrics: %w", err)
|
||||
}
|
||||
|
||||
// There are 96 CSS pixels per inch.
|
||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||
paperHeight = cssContentSize.Height / 96
|
||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||
}
|
||||
|
||||
printToPdf := page.PrintToPDF().
|
||||
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
|
||||
WithLandscape(options.Landscape).
|
||||
WithPrintBackground(options.PrintBackground).
|
||||
WithScale(options.Scale).
|
||||
WithPaperWidth(options.PaperWidth).
|
||||
WithPaperHeight(options.PaperHeight).
|
||||
WithPaperHeight(paperHeight).
|
||||
WithMarginTop(options.MarginTop).
|
||||
WithMarginBottom(options.MarginBottom).
|
||||
WithMarginLeft(options.MarginLeft).
|
||||
WithMarginRight(options.MarginRight).
|
||||
WithPageRanges(options.PageRanges).
|
||||
WithPreferCSSPageSize(options.PreferCssPageSize)
|
||||
WithPageRanges(pageRanges).
|
||||
WithPreferCSSPageSize(options.PreferCssPageSize).
|
||||
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1210.
|
||||
WithGenerateTaggedPDF(options.GenerateTaggedPdf)
|
||||
|
||||
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
|
||||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
|
||||
@@ -100,6 +121,14 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options
|
||||
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
||||
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
||||
|
||||
if options.Clip {
|
||||
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
|
||||
Width: float64(options.Width),
|
||||
Height: float64(options.Height),
|
||||
Scale: 1,
|
||||
})
|
||||
}
|
||||
|
||||
if options.Format == "jpeg" {
|
||||
captureScreenshot = captureScreenshot.
|
||||
WithQuality(int64(options.Quality))
|
||||
@@ -133,6 +162,19 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options
|
||||
}
|
||||
}
|
||||
|
||||
func setDeviceMetricsOverride(logger *zap.Logger, width, height int) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
logger.Debug("set device metrics override")
|
||||
|
||||
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("set device metrics override: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/753.
|
||||
@@ -190,29 +232,100 @@ func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.Acti
|
||||
}
|
||||
}
|
||||
|
||||
func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
|
||||
func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if len(extraHttpHeaders) == 0 {
|
||||
logger.Debug("no extra HTTP headers")
|
||||
if len(cookies) == 0 {
|
||||
logger.Debug("no cookies to set")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return errors.New("context has no deadline, cannot set cookies")
|
||||
}
|
||||
epochTime := cdp.TimeSinceEpoch(deadline)
|
||||
|
||||
headers := make(network.Headers, len(extraHttpHeaders))
|
||||
for key, value := range extraHttpHeaders {
|
||||
headers[key] = value
|
||||
cookiePretty := func(c *network.SetCookieParams) string {
|
||||
return fmt.Sprintf(
|
||||
"Name: '%s', Value: '%s', Domain: '%s', Path: '%s', Secure: %t, HTTPOnly: %t, SameSite: '%s', Expires: %s",
|
||||
c.Name,
|
||||
c.Value,
|
||||
c.Domain,
|
||||
c.Path,
|
||||
c.Secure,
|
||||
c.HTTPOnly,
|
||||
c.SameSite.String(),
|
||||
c.Expires.Time().String(),
|
||||
)
|
||||
}
|
||||
|
||||
err := network.SetExtraHTTPHeaders(headers).Do(ctx)
|
||||
for _, cookie := range cookies {
|
||||
cookieParams := network.
|
||||
SetCookie(cookie.Name, cookie.Value).
|
||||
WithDomain(cookie.Domain).
|
||||
WithPath(cookie.Path).
|
||||
WithSecure(cookie.Secure).
|
||||
WithHTTPOnly(cookie.HttpOnly).
|
||||
WithSameSite(cookie.SameSite).
|
||||
WithExpires(&epochTime)
|
||||
|
||||
err := cookieParams.Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set cookie %s: %w", cookiePretty(cookieParams), err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("set cookie %s", cookiePretty(cookieParams)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if len(userAgent) == 0 {
|
||||
logger.Debug("no user agent override")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("user agent override: %s", userAgent))
|
||||
err := emulation.SetUserAgentOverride(userAgent).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("set extra HTTP headers: %w", err)
|
||||
return fmt.Errorf("set user agent override: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// This code has been replaced with the listenForEventRequestPaused function.
|
||||
// Indeed, the user may want to scope the headers per domain, but using
|
||||
// network.SetExtraHTTPHeaders set the headers for ALL requests from the page.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||
//
|
||||
//func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
|
||||
// return func(ctx context.Context) error {
|
||||
// if len(extraHttpHeaders) == 0 {
|
||||
// logger.Debug("no extra HTTP headers")
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
|
||||
//
|
||||
// headers := make(network.Headers, len(extraHttpHeaders))
|
||||
// for key, value := range extraHttpHeaders {
|
||||
// headers[key] = value
|
||||
// }
|
||||
//
|
||||
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
|
||||
// if err == nil {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// return fmt.Errorf("set extra HTTP headers: %w", err)
|
||||
// }
|
||||
//}
|
||||
|
||||
func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
logger.Debug(fmt.Sprintf("navigate to '%s'", url))
|
||||
@@ -278,26 +391,30 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr
|
||||
}
|
||||
}
|
||||
|
||||
func forceExactColorsActionFunc() chromedp.ActionFunc {
|
||||
func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/354
|
||||
// https://github.com/puppeteer/puppeteer/issues/2685
|
||||
// https://github.com/chromedp/chromedp/issues/520
|
||||
script := `
|
||||
(() => {
|
||||
const css = 'html { -webkit-print-color-adjust: exact !important; }';
|
||||
css := "html { -webkit-print-color-adjust: exact !important; }"
|
||||
if !printBackground {
|
||||
// The -webkit-print-color-adjust: exact CSS property forces the
|
||||
// print of the background, whatever the printToPDF args.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1154.
|
||||
additionalCss := "html, body { background: none !important; }"
|
||||
logger.Debug(fmt.Sprintf("inject %s as printBackground is %t", additionalCss, printBackground))
|
||||
css += additionalCss
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(() => {
|
||||
const css = '%s';
|
||||
const style = document.createElement('style');
|
||||
style.type = 'text/css';
|
||||
style.appendChild(document.createTextNode(css));
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
`
|
||||
`, css)
|
||||
|
||||
evaluate := chromedp.Evaluate(script, nil)
|
||||
err := evaluate.Do(ctx)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
11
pkg/modules/exiftool/doc.go
Normal file
11
pkg/modules/exiftool/doc.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Package exiftool provides an implementation of the gotenberg.PdfEngine
|
||||
// interface using the ExifTool command-line tool. This package allows for:
|
||||
//
|
||||
// 1. The reading of metadata.
|
||||
// 2. The writing of metadata.
|
||||
//
|
||||
// The path to the exiftool binary must be specified using the
|
||||
// EXIFTOOL_BIN_PATH environment variable.
|
||||
//
|
||||
// See: https://exiftool.org.
|
||||
package exiftool
|
||||
186
pkg/modules/exiftool/exiftool.go
Normal file
186
pkg/modules/exiftool/exiftool.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/barasher/go-exiftool"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(new(ExifTool))
|
||||
}
|
||||
|
||||
// ExifTool abstracts the CLI tool ExifTool and implements the
|
||||
// [gotenberg.PdfEngine] interface.
|
||||
type ExifTool struct {
|
||||
binPath string
|
||||
}
|
||||
|
||||
// Descriptor returns [ExifTool]'s module descriptor.
|
||||
func (engine *ExifTool) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "exiftool",
|
||||
New: func() gotenberg.Module { return new(ExifTool) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties.
|
||||
func (engine *ExifTool) Provision(ctx *gotenberg.Context) error {
|
||||
binPath, ok := os.LookupEnv("EXIFTOOL_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("EXIFTOOL_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
engine.binPath = binPath
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the module properties.
|
||||
func (engine *ExifTool) Validate() error {
|
||||
_, err := os.Stat(engine.binPath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("ExifTool binary path does not exist: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Debug returns additional debug data.
|
||||
func (engine *ExifTool) Debug() map[string]interface{} {
|
||||
debug := make(map[string]interface{})
|
||||
|
||||
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
debug["version"] = err.Error()
|
||||
return debug
|
||||
}
|
||||
|
||||
debug["version"] = strings.TrimSpace(string(output))
|
||||
return debug
|
||||
}
|
||||
|
||||
// Merge is not available in this implementation.
|
||||
func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Split is not available in this implementation.
|
||||
func (engine *ExifTool) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||
return nil, fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Flatten is not available in this implementation.
|
||||
func (engine *ExifTool) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
|
||||
return fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Convert is not available in this implementation.
|
||||
func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// ReadMetadata extracts the metadata of a given PDF file.
|
||||
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new ExifTool: %w", err)
|
||||
}
|
||||
|
||||
defer func(exifTool *exiftool.Exiftool) {
|
||||
err := exifTool.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close ExifTool: %v", err))
|
||||
}
|
||||
}(exifTool)
|
||||
|
||||
fileMetadata := exifTool.ExtractMetadata(inputPath)
|
||||
if fileMetadata[0].Err != nil {
|
||||
return nil, fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
|
||||
}
|
||||
|
||||
return fileMetadata[0].Fields, nil
|
||||
}
|
||||
|
||||
// WriteMetadata writes the metadata into a given PDF file.
|
||||
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("new ExifTool: %w", err)
|
||||
}
|
||||
|
||||
defer func(exifTool *exiftool.Exiftool) {
|
||||
err := exifTool.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close ExifTool: %v", err))
|
||||
}
|
||||
}(exifTool)
|
||||
|
||||
fileMetadata := exifTool.ExtractMetadata(inputPath)
|
||||
if fileMetadata[0].Err != nil {
|
||||
return fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
|
||||
}
|
||||
|
||||
for key, value := range metadata {
|
||||
switch val := value.(type) {
|
||||
case string:
|
||||
fileMetadata[0].SetString(key, val)
|
||||
case []string:
|
||||
fileMetadata[0].SetStrings(key, val)
|
||||
case []interface{}:
|
||||
// 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
|
||||
}
|
||||
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||
}
|
||||
fileMetadata[0].SetStrings(key, strs)
|
||||
case bool:
|
||||
fileMetadata[0].SetString(key, fmt.Sprintf("%t", val))
|
||||
case int:
|
||||
fileMetadata[0].SetInt(key, int64(val))
|
||||
case int64:
|
||||
fileMetadata[0].SetInt(key, val)
|
||||
case float32:
|
||||
fileMetadata[0].SetFloat(key, float64(val))
|
||||
case float64:
|
||||
fileMetadata[0].SetFloat(key, val)
|
||||
// TODO: support more complex cases, e.g., arrays and nested objects
|
||||
// (limitations in underlying library).
|
||||
default:
|
||||
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||
}
|
||||
}
|
||||
|
||||
exifTool.WriteMetadata(fileMetadata)
|
||||
if fileMetadata[0].Err != nil {
|
||||
return fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*ExifTool)(nil)
|
||||
_ gotenberg.Provisioner = (*ExifTool)(nil)
|
||||
_ gotenberg.Validator = (*ExifTool)(nil)
|
||||
_ gotenberg.Debuggable = (*ExifTool)(nil)
|
||||
_ gotenberg.PdfEngine = (*ExifTool)(nil)
|
||||
)
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
@@ -21,16 +24,23 @@ func init() {
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrInvalidPdfFormats happens if the PDF formats option cannot be handled
|
||||
// by LibreOffice.
|
||||
// ErrInvalidPdfFormats happens if LibreOffice cannot handle the PDF
|
||||
// formats option.
|
||||
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
|
||||
|
||||
// ErrMalformedPageRanges happens if the page ranges option cannot be
|
||||
// interpreted by LibreOffice.
|
||||
ErrMalformedPageRanges = errors.New("page ranges are malformed")
|
||||
// ErrUnoException happens when unoconverter returns exit code 5.
|
||||
ErrUnoException = errors.New("uno exception")
|
||||
|
||||
// ErrRuntimeException happens when unoconverter returns exit code 6.
|
||||
ErrRuntimeException = errors.New("uno exception")
|
||||
|
||||
// ErrCoreDumped happens randomly; sometimes a conversion will work as
|
||||
// expected, and some other time the same conversion will fail.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||
ErrCoreDumped = errors.New("core dumped")
|
||||
)
|
||||
|
||||
// Api is a module which provides a [Uno] to interact with LibreOffice.
|
||||
// Api is a module that provides a [Uno] to interact with LibreOffice.
|
||||
type Api struct {
|
||||
autoStart bool
|
||||
args libreOfficeArguments
|
||||
@@ -41,28 +51,147 @@ type Api struct {
|
||||
}
|
||||
|
||||
// Options gathers available options when converting a document to PDF.
|
||||
// See: https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html.
|
||||
type Options struct {
|
||||
// Landscape allows to change the orientation of the resulting PDF.
|
||||
// Optional.
|
||||
// Password specifies the password for opening the source file.
|
||||
Password string
|
||||
|
||||
// Landscape allows changing the orientation of the resulting PDF.
|
||||
Landscape bool
|
||||
|
||||
// PageRanges allows to select the pages to convert.
|
||||
// Optional.
|
||||
// PageRanges allows selecting the pages to convert.
|
||||
PageRanges string
|
||||
|
||||
// UpdateIndexes specifies whether to update the indexes before conversion,
|
||||
// keeping in mind that doing so might result in missing links in the final
|
||||
// PDF.
|
||||
UpdateIndexes bool
|
||||
|
||||
// ExportFormFields specifies whether form fields are exported as widgets
|
||||
// or only their fixed print representation is exported.
|
||||
ExportFormFields bool
|
||||
|
||||
// AllowDuplicateFieldNames specifies whether multiple form fields exported
|
||||
// are allowed to have the same field name.
|
||||
AllowDuplicateFieldNames bool
|
||||
|
||||
// ExportBookmarks specifies if bookmarks are exported to PDF.
|
||||
ExportBookmarks bool
|
||||
|
||||
// ExportBookmarksToPdfDestination specifies that the bookmarks contained
|
||||
// in the source LibreOffice file should be exported to the PDF file as
|
||||
// Named Destination.
|
||||
ExportBookmarksToPdfDestination bool
|
||||
|
||||
// ExportPlaceholders exports the placeholder fields visual markings only.
|
||||
// The exported placeholder is ineffective.
|
||||
ExportPlaceholders bool
|
||||
|
||||
// ExportNotes specifies if notes are exported to PDF.
|
||||
ExportNotes bool
|
||||
|
||||
// ExportNotesPages specifies if notes pages are exported to PDF.
|
||||
// Notes pages are available in Impress documents only.
|
||||
ExportNotesPages bool
|
||||
|
||||
// ExportOnlyNotesPages specifies if the property ExportNotesPages is set
|
||||
// to true if only notes pages are exported to PDF.
|
||||
ExportOnlyNotesPages bool
|
||||
|
||||
// ExportNotesInMargin specifies if notes in the margin are exported to
|
||||
// PDF.
|
||||
ExportNotesInMargin bool
|
||||
|
||||
// ConvertOooTargetToPdfTarget specifies that the target documents with
|
||||
// .od[tpgs] extension will have that extension changed to .pdf when the
|
||||
// link is exported to PDF. The source document remains untouched.
|
||||
ConvertOooTargetToPdfTarget bool
|
||||
|
||||
// ExportLinksRelativeFsys specifies that the file system related
|
||||
// hyperlinks (file:// protocol) present in the document will be exported
|
||||
// as relative to the source document location.
|
||||
ExportLinksRelativeFsys bool
|
||||
|
||||
// ExportHiddenSlides exports, for LibreOffice Impress, slides that are not
|
||||
// included in slide shows.
|
||||
ExportHiddenSlides bool
|
||||
|
||||
// SkipEmptyPages specifies that automatically inserted empty pages are
|
||||
// suppressed. This option is active only if storing Writer documents.
|
||||
SkipEmptyPages bool
|
||||
|
||||
// AddOriginalDocumentAsStream specifies that a stream is inserted to the
|
||||
// PDF file which contains the original document for archiving purposes.
|
||||
AddOriginalDocumentAsStream bool
|
||||
|
||||
// SinglePageSheets ignores each sheet’s paper size, print ranges and
|
||||
// shown/hidden status and puts every sheet (even hidden sheets) on exactly
|
||||
// one page.
|
||||
SinglePageSheets bool
|
||||
|
||||
// LosslessImageCompression specifies if images are exported to PDF using
|
||||
// a lossless compression format like PNG or compressed using the JPEG
|
||||
// format.
|
||||
LosslessImageCompression bool
|
||||
|
||||
// Quality specifies the quality of the JPG export. A higher value produces
|
||||
// a higher-quality image and a larger file. Between 1 and 100.
|
||||
Quality int
|
||||
|
||||
// ReduceImageResolution specifies if the resolution of each image is
|
||||
// reduced to the resolution specified by the property MaxImageResolution.
|
||||
ReduceImageResolution bool
|
||||
|
||||
// MaxImageResolution, if the property ReduceImageResolution is set to
|
||||
// true, tells if all images will be reduced to the given value in DPI.
|
||||
// Possible values are: 75, 150, 300, 600 and 1200.
|
||||
MaxImageResolution int
|
||||
|
||||
// PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
|
||||
// PDF/A-3b and PDF/UA.
|
||||
// Optional.
|
||||
PdfFormats gotenberg.PdfFormats
|
||||
}
|
||||
|
||||
// DefaultOptions returns the default values for Options.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
Password: "",
|
||||
Landscape: false,
|
||||
PageRanges: "",
|
||||
UpdateIndexes: true,
|
||||
ExportFormFields: true,
|
||||
AllowDuplicateFieldNames: false,
|
||||
ExportBookmarks: true,
|
||||
ExportBookmarksToPdfDestination: false,
|
||||
ExportPlaceholders: false,
|
||||
ExportNotes: false,
|
||||
ExportNotesPages: false,
|
||||
ExportOnlyNotesPages: false,
|
||||
ExportNotesInMargin: false,
|
||||
ConvertOooTargetToPdfTarget: false,
|
||||
ExportLinksRelativeFsys: false,
|
||||
ExportHiddenSlides: false,
|
||||
SkipEmptyPages: false,
|
||||
AddOriginalDocumentAsStream: false,
|
||||
SinglePageSheets: false,
|
||||
LosslessImageCompression: false,
|
||||
Quality: 90,
|
||||
ReduceImageResolution: false,
|
||||
MaxImageResolution: 300,
|
||||
PdfFormats: gotenberg.PdfFormats{
|
||||
PdfA: "",
|
||||
PdfUa: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Uno is an abstraction on top of the Universal Network Objects API.
|
||||
type Uno interface {
|
||||
Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
Extensions() []string
|
||||
}
|
||||
|
||||
// Provider is a module interface which exposes a method for creating a
|
||||
// Provider is a module interface that exposes a method for creating a
|
||||
// [Uno] for other modules.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
@@ -80,6 +209,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("api", flag.ExitOnError)
|
||||
fs.Int64("libreoffice-restart-after", 10, "Number of conversions after which LibreOffice will automatically restart. Set to 0 to disable this feature")
|
||||
fs.Int64("libreoffice-max-queue-size", 0, "Maximum request queue size for LibreOffice. Set to 0 to disable this feature")
|
||||
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
|
||||
fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
|
||||
|
||||
@@ -123,7 +253,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
|
||||
// Process.
|
||||
a.libreOffice = newLibreOfficeProcess(a.args)
|
||||
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"))
|
||||
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -171,8 +301,8 @@ func (a *Api) StartupMessage() string {
|
||||
|
||||
// Stop stops the current browser instance.
|
||||
func (a *Api) Stop(ctx context.Context) error {
|
||||
// Block until the context is done so that other module may gracefully stop
|
||||
// before we do a shutdown.
|
||||
// Block until the context is done so that another module may gracefully
|
||||
// stop before we do a shutdown.
|
||||
a.logger.Debug("wait for the end of grace duration")
|
||||
|
||||
<-ctx.Done()
|
||||
@@ -185,6 +315,23 @@ func (a *Api) Stop(ctx context.Context) error {
|
||||
return fmt.Errorf("stop LibreOffice: %w", err)
|
||||
}
|
||||
|
||||
// Debug returns additional debug data.
|
||||
func (a *Api) Debug() map[string]interface{} {
|
||||
debug := make(map[string]interface{})
|
||||
|
||||
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
debug["version"] = err.Error()
|
||||
return debug
|
||||
}
|
||||
|
||||
debug["version"] = strings.TrimSpace(string(output))
|
||||
return debug
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
func (a *Api) Metrics() ([]gotenberg.Metric, error) {
|
||||
return []gotenberg.Metric{
|
||||
@@ -256,94 +403,157 @@ func (a *Api) LibreOffice() (Uno, error) {
|
||||
|
||||
// Pdf converts a document to PDF.
|
||||
func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
return a.supervisor.Run(ctx, logger, func() error {
|
||||
err := a.supervisor.Run(ctx, logger, func() error {
|
||||
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||
if errors.Is(err, ErrCoreDumped) {
|
||||
logger.Debug(fmt.Sprintf("got a '%s' error, retry conversion", err))
|
||||
return a.Pdf(ctx, logger, inputPath, outputPath, options)
|
||||
}
|
||||
|
||||
return fmt.Errorf("supervisor run task: %w", err)
|
||||
}
|
||||
|
||||
// Extensions returns the file extensions available for conversions.
|
||||
// FIXME: don't care, take all on the route level?
|
||||
func (a *Api) Extensions() []string {
|
||||
return []string{
|
||||
".123",
|
||||
".602",
|
||||
".abw",
|
||||
".bib",
|
||||
".doc",
|
||||
".xml",
|
||||
".docx",
|
||||
".fodt",
|
||||
".html",
|
||||
".ltx",
|
||||
".txt",
|
||||
".odt",
|
||||
".ott",
|
||||
".pdb",
|
||||
".pdf",
|
||||
".psw",
|
||||
".rtf",
|
||||
".sdw",
|
||||
".stw",
|
||||
".sxw",
|
||||
".uot",
|
||||
".vor",
|
||||
".wps",
|
||||
".epub",
|
||||
".png",
|
||||
".bmp",
|
||||
".cdr",
|
||||
".cgm",
|
||||
".cmx",
|
||||
".csv",
|
||||
".cwk",
|
||||
".dbf",
|
||||
".dif",
|
||||
".doc",
|
||||
".docm",
|
||||
".docx",
|
||||
".dot",
|
||||
".dotm",
|
||||
".dotx",
|
||||
".dxf",
|
||||
".emf",
|
||||
".eps",
|
||||
".epub",
|
||||
".fodg",
|
||||
".fodp",
|
||||
".fods",
|
||||
".fodt",
|
||||
".fopd",
|
||||
".gif",
|
||||
".jpg",
|
||||
".htm",
|
||||
".html",
|
||||
".hwp",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".key",
|
||||
".ltx",
|
||||
".lwp",
|
||||
".mcw",
|
||||
".met",
|
||||
".mml",
|
||||
".mw",
|
||||
".numbers",
|
||||
".odd",
|
||||
".odg",
|
||||
".odm",
|
||||
".odp",
|
||||
".ods",
|
||||
".odt",
|
||||
".otg",
|
||||
".oth",
|
||||
".otp",
|
||||
".ots",
|
||||
".ott",
|
||||
".pages",
|
||||
".pbm",
|
||||
".pcd",
|
||||
".pct",
|
||||
".pcx",
|
||||
".pdb",
|
||||
".pdf",
|
||||
".pgm",
|
||||
".png",
|
||||
".pot",
|
||||
".potm",
|
||||
".potx",
|
||||
".ppm",
|
||||
".pps",
|
||||
".ppt",
|
||||
".pptm",
|
||||
".pptx",
|
||||
".psd",
|
||||
".psw",
|
||||
".pub",
|
||||
".pwp",
|
||||
".pxl",
|
||||
".ras",
|
||||
".rtf",
|
||||
".sda",
|
||||
".sdc",
|
||||
".sdd",
|
||||
".sdp",
|
||||
".sdw",
|
||||
".sgl",
|
||||
".slk",
|
||||
".smf",
|
||||
".stc",
|
||||
".std",
|
||||
".sti",
|
||||
".stw",
|
||||
".svg",
|
||||
".svm",
|
||||
".swf",
|
||||
".sxc",
|
||||
".sxd",
|
||||
".sxg",
|
||||
".sxi",
|
||||
".sxm",
|
||||
".sxw",
|
||||
".tga",
|
||||
".tif",
|
||||
".tiff",
|
||||
".xhtml",
|
||||
".xpm",
|
||||
".odp",
|
||||
".fodp",
|
||||
".potm",
|
||||
".pot",
|
||||
".pptx",
|
||||
".pps",
|
||||
".ppt",
|
||||
".pwp",
|
||||
".sda",
|
||||
".sdd",
|
||||
".sti",
|
||||
".sxi",
|
||||
".txt",
|
||||
".uof",
|
||||
".uop",
|
||||
".wmf",
|
||||
".csv",
|
||||
".dbf",
|
||||
".dif",
|
||||
".fods",
|
||||
".ods",
|
||||
".ots",
|
||||
".pxl",
|
||||
".sdc",
|
||||
".slk",
|
||||
".stc",
|
||||
".sxc",
|
||||
".uos",
|
||||
".uot",
|
||||
".vdx",
|
||||
".vor",
|
||||
".vsd",
|
||||
".vsdm",
|
||||
".vsdx",
|
||||
".wb2",
|
||||
".wk1",
|
||||
".wks",
|
||||
".wmf",
|
||||
".wpd",
|
||||
".wpg",
|
||||
".wps",
|
||||
".xbm",
|
||||
".xhtml",
|
||||
".xls",
|
||||
".xlt",
|
||||
".xlsb",
|
||||
".xlsm",
|
||||
".xlsx",
|
||||
".odg",
|
||||
".dotx",
|
||||
".xlt",
|
||||
".xltm",
|
||||
".xltx",
|
||||
".xlw",
|
||||
".xml",
|
||||
".xpm",
|
||||
".zabw",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +563,7 @@ var (
|
||||
_ gotenberg.Provisioner = (*Api)(nil)
|
||||
_ gotenberg.Validator = (*Api)(nil)
|
||||
_ gotenberg.App = (*Api)(nil)
|
||||
_ gotenberg.Debuggable = (*Api)(nil)
|
||||
_ gotenberg.MetricsProvider = (*Api)(nil)
|
||||
_ api.HealthChecker = (*Api)(nil)
|
||||
_ Uno = (*Api)(nil)
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestApi_Descriptor(t *testing.T) {
|
||||
descriptor := new(Api).Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(Api))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Provision(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *gotenberg.Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "no logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no logger from logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "provision success",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return zap.NewNop(), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
err := a.Provision(tc.ctx)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Validate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
binPath string
|
||||
unoBinPath string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "empty LibreOffice bin path",
|
||||
binPath: "",
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "LibreOffice bin path does not exist",
|
||||
binPath: "/foo",
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "empty uno bin path",
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
unoBinPath: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "uno bin path does not exist",
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
unoBinPath: "/foo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "validate success",
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.args = libreOfficeArguments{
|
||||
binPath: tc.binPath,
|
||||
unoBinPath: tc.unoBinPath,
|
||||
}
|
||||
err := a.Validate()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Start(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
autoStart bool
|
||||
supervisor *gotenberg.ProcessSupervisorMock
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "no auto-start",
|
||||
autoStart: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start success",
|
||||
autoStart: true,
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start failed",
|
||||
autoStart: true,
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
|
||||
return errors.New("foo")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.autoStart = tc.autoStart
|
||||
a.supervisor = tc.supervisor
|
||||
|
||||
err := a.Start()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_StartupMessage(t *testing.T) {
|
||||
a := new(Api)
|
||||
|
||||
a.autoStart = true
|
||||
autoStartMsg := a.StartupMessage()
|
||||
|
||||
a.autoStart = false
|
||||
noAutoStartMsg := a.StartupMessage()
|
||||
|
||||
if autoStartMsg == noAutoStartMsg {
|
||||
t.Errorf("expected differrent startup messages based on auto start, but got '%s'", autoStartMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Stop(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor *gotenberg.ProcessSupervisorMock
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "stop success",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "stop failed",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
|
||||
return errors.New("foo")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.logger = zap.NewNop()
|
||||
a.supervisor = tc.supervisor
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second)
|
||||
cancel()
|
||||
|
||||
err := a.Stop(ctx)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Metrics(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.supervisor = &gotenberg.ProcessSupervisorMock{
|
||||
ReqQueueSizeMock: func() int64 {
|
||||
return 10
|
||||
},
|
||||
RestartsCountMock: func() int64 {
|
||||
return 0
|
||||
},
|
||||
}
|
||||
|
||||
metrics, err := a.Metrics()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if len(metrics) != 2 {
|
||||
t.Fatalf("expected %d metrics, but got %d", 2, len(metrics))
|
||||
}
|
||||
|
||||
actual := metrics[0].Read()
|
||||
if actual != float64(10) {
|
||||
t.Errorf("expected %f for libreoffice_requests_queue_size, but got %f", float64(10), actual)
|
||||
}
|
||||
|
||||
actual = metrics[1].Read()
|
||||
if actual != float64(0) {
|
||||
t.Errorf("expected %f for libreoffice_restarts_count, but got %f", float64(0), actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Checks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
expectAvailabilityStatus health.AvailabilityStatus
|
||||
}{
|
||||
{
|
||||
scenario: "healthy module",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
|
||||
return true
|
||||
}},
|
||||
expectAvailabilityStatus: health.StatusUp,
|
||||
},
|
||||
{
|
||||
scenario: "unhealthy module",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
|
||||
return false
|
||||
}},
|
||||
expectAvailabilityStatus: health.StatusDown,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.supervisor = tc.supervisor
|
||||
|
||||
checks, err := a.Checks()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
checker := health.NewChecker(checks...)
|
||||
result := checker.Check(context.Background())
|
||||
|
||||
if result.Status != tc.expectAvailabilityStatus {
|
||||
t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Ready(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
autoStart bool
|
||||
startTimeout time.Duration
|
||||
libreOffice libreOffice
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "no auto-start",
|
||||
autoStart: false,
|
||||
startTimeout: time.Duration(30) * time.Second,
|
||||
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||
return false
|
||||
}}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start: context done",
|
||||
autoStart: true,
|
||||
startTimeout: time.Duration(200) * time.Millisecond,
|
||||
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||
return false
|
||||
}}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start success",
|
||||
autoStart: true,
|
||||
startTimeout: time.Duration(30) * time.Second,
|
||||
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||
return true
|
||||
}}},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.autoStart = tc.autoStart
|
||||
a.args = libreOfficeArguments{startTimeout: tc.startTimeout}
|
||||
a.libreOffice = tc.libreOffice
|
||||
|
||||
err := a.Ready()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_LibreOffice(t *testing.T) {
|
||||
a := new(Api)
|
||||
|
||||
_, err := a.LibreOffice()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Pdf(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
libreOffice libreOffice
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "PDF task success",
|
||||
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "PDF task error",
|
||||
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return errors.New("PDF task error")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
return task()
|
||||
}}
|
||||
a.libreOffice = tc.libreOffice
|
||||
|
||||
err := a.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Extensions(t *testing.T) {
|
||||
a := new(Api)
|
||||
extensions := a.Extensions()
|
||||
|
||||
actual := len(extensions)
|
||||
expect := 79
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %d extensions, but got %d", expect, actual)
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
@@ -43,7 +41,7 @@ type libreOfficeProcess struct {
|
||||
func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice {
|
||||
p := &libreOfficeProcess{
|
||||
arguments: arguments,
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
|
||||
}
|
||||
p.isStarted.Store(false)
|
||||
|
||||
@@ -189,22 +187,23 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
|
||||
|
||||
// Always remove the user profile directory created by LibreOffice.
|
||||
copyUserProfileDirPath := p.userProfileDirPath
|
||||
defer func(userProfileDirPath string) {
|
||||
expirationTime := time.Now()
|
||||
defer func(userProfileDirPath string, expirationTime time.Time) {
|
||||
go func() {
|
||||
err := os.RemoveAll(userProfileDirPath)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
|
||||
} else {
|
||||
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
|
||||
|
||||
// Also remove LibreOffice specific files in the temporary directory.
|
||||
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"})
|
||||
// Also, remove LibreOffice specific files in the temporary directory.
|
||||
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
}()
|
||||
}(copyUserProfileDirPath)
|
||||
}(copyUserProfileDirPath, expirationTime)
|
||||
|
||||
p.cfgMu.Lock()
|
||||
defer p.cfgMu.Unlock()
|
||||
@@ -265,14 +264,44 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
|
||||
args = append(args, "-vvv")
|
||||
}
|
||||
|
||||
if options.Password != "" {
|
||||
args = append(args, "--password", options.Password)
|
||||
}
|
||||
|
||||
if options.Landscape {
|
||||
args = append(args, "--printer", "PaperOrientation=landscape")
|
||||
}
|
||||
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/1149.
|
||||
if options.PageRanges != "" {
|
||||
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
|
||||
}
|
||||
|
||||
if !options.UpdateIndexes {
|
||||
args = append(args, "--disable-update-indexes")
|
||||
}
|
||||
|
||||
args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields))
|
||||
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportBookmarksToPDFDestination=%t", options.ExportBookmarksToPdfDestination))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportPlaceholders=%t", options.ExportPlaceholders))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportNotes=%t", options.ExportNotes))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportNotesPages=%t", options.ExportNotesPages))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportOnlyNotesPages=%t", options.ExportOnlyNotesPages))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportNotesInMargin=%t", options.ExportNotesInMargin))
|
||||
args = append(args, "--export", fmt.Sprintf("ConvertOOoTargetToPDFTarget=%t", options.ConvertOooTargetToPdfTarget))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportLinksRelativeFsys=%t", options.ExportLinksRelativeFsys))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportHiddenSlides=%t", options.ExportHiddenSlides))
|
||||
args = append(args, "--export", fmt.Sprintf("IsSkipEmptyPages=%t", options.SkipEmptyPages))
|
||||
args = append(args, "--export", fmt.Sprintf("IsAddStream=%t", options.AddOriginalDocumentAsStream))
|
||||
args = append(args, "--export", fmt.Sprintf("SinglePageSheets=%t", options.SinglePageSheets))
|
||||
args = append(args, "--export", fmt.Sprintf("UseLosslessCompression=%t", options.LosslessImageCompression))
|
||||
args = append(args, "--export", fmt.Sprintf("Quality=%d", options.Quality))
|
||||
args = append(args, "--export", fmt.Sprintf("ReduceImageResolution=%t", options.ReduceImageResolution))
|
||||
args = append(args, "--export", fmt.Sprintf("MaxImageResolution=%d", options.MaxImageResolution))
|
||||
|
||||
switch options.PdfFormats.PdfA {
|
||||
case "":
|
||||
case gotenberg.PdfA1b:
|
||||
@@ -288,14 +317,17 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
|
||||
if options.PdfFormats.PdfUa {
|
||||
args = append(
|
||||
args,
|
||||
"--export", "EnableTextAccessForAccessibilityTools=true",
|
||||
"--export", "PDFUACompliance=true",
|
||||
"--export", "UseTaggedPDF=true",
|
||||
"--export", "EnableTextAccessForAccessibilityTools=true",
|
||||
)
|
||||
} else {
|
||||
args = append(
|
||||
args,
|
||||
"--export", "PDFUACompliance=false",
|
||||
"--export", "UseTaggedPDF=false",
|
||||
"--export", "EnableTextAccessForAccessibilityTools=false",
|
||||
)
|
||||
}
|
||||
|
||||
inputPath, err := nonBasicLatinCharactersGuard(logger, inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("non-basic latin characters guard: %w", err)
|
||||
}
|
||||
|
||||
args = append(args, "--output", outputPath, inputPath)
|
||||
@@ -313,81 +345,27 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
|
||||
}
|
||||
|
||||
// LibreOffice's errors are not explicit.
|
||||
// That's why we have to make an educated guess according to the exit code
|
||||
// and given inputs.
|
||||
if exitCode == 5 && options.PageRanges != "" {
|
||||
return ErrMalformedPageRanges
|
||||
// For instance, exit code 5 may be explained by a malformed page range
|
||||
// but also by a not required password.
|
||||
|
||||
// We may want to retry in case of a core-dumped event.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||
if strings.Contains(err.Error(), "core dumped") {
|
||||
return ErrCoreDumped
|
||||
}
|
||||
|
||||
if exitCode == 5 {
|
||||
// Potentially malformed page ranges or password not required.
|
||||
return ErrUnoException
|
||||
}
|
||||
if exitCode == 6 {
|
||||
// Password potentially required or invalid.
|
||||
return ErrRuntimeException
|
||||
}
|
||||
|
||||
// Possible errors:
|
||||
// 1. LibreOffice failed for some reason.
|
||||
// 2. Context done.
|
||||
//
|
||||
// On the second scenario, LibreOffice might not have time to remove some
|
||||
// of its temporary files, as it has been killed without warning. The
|
||||
// garbage collector will delete them for us (if the module is loaded).
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
}
|
||||
|
||||
// LibreOffice cannot convert a file with a name containing non-basic Latin
|
||||
// characters.
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/104
|
||||
// https://github.com/gotenberg/gotenberg/issues/730
|
||||
func nonBasicLatinCharactersGuard(logger *zap.Logger, inputPath string) (string, error) {
|
||||
hasNonBasicLatinChars := func(str string) bool {
|
||||
for _, r := range str {
|
||||
// Check if the character is outside basic Latin.
|
||||
if r != '.' && (r < ' ' || r > '~') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
filename := filepath.Base(inputPath)
|
||||
if !hasNonBasicLatinChars(filename) {
|
||||
logger.Debug("no non-basic latin characters in filename, skip copy")
|
||||
return inputPath, nil
|
||||
}
|
||||
|
||||
logger.Warn("non-basic latin characters in filename, copy to a file with a valid filename")
|
||||
basePath := filepath.Dir(inputPath)
|
||||
ext := filepath.Ext(inputPath)
|
||||
newInputPath := filepath.Join(basePath, fmt.Sprintf("%s%s", uuid.NewString(), ext))
|
||||
|
||||
in, err := os.Open(inputPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open file: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := in.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close file: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := os.Create(newInputPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create new file: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close new file: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("copy file to new file: %w", err)
|
||||
}
|
||||
|
||||
return newInputPath, nil
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Process = (*libreOfficeProcess)(nil)
|
||||
|
||||
@@ -1,650 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestLibreOfficeProcess_Start(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
libreOffice libreOffice
|
||||
expectError bool
|
||||
cleanup bool
|
||||
}{
|
||||
{
|
||||
scenario: "successful start",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
expectError: false,
|
||||
cleanup: true,
|
||||
},
|
||||
{
|
||||
scenario: "LibreOffice already started",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(true)
|
||||
return p
|
||||
}(),
|
||||
expectError: true,
|
||||
cleanup: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-exit code 81 on first start",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: "foo",
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
expectError: true,
|
||||
cleanup: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
err := tc.libreOffice.Start(logger)
|
||||
|
||||
if tc.cleanup {
|
||||
defer func(p libreOffice, logger *zap.Logger) {
|
||||
err = p.Stop(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}(tc.libreOffice, logger)
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeProcess_Stop(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
libreOffice libreOffice
|
||||
setup func(libreOffice libreOffice, logger *zap.Logger) error
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "successful stop",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
setup: func(p libreOffice, logger *zap.Logger) error {
|
||||
return p.Start(logger)
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "LibreOffice already stopped",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(false)
|
||||
return p
|
||||
}(),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
if tc.setup != nil {
|
||||
err := tc.setup(tc.libreOffice, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("setup error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := tc.libreOffice.Stop(logger)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeProcess_Healthy(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
libreOffice libreOffice
|
||||
setup func(libreOffice libreOffice, logger *zap.Logger) error
|
||||
expectHealthy bool
|
||||
cleanup bool
|
||||
}{
|
||||
{
|
||||
scenario: "healthy LibreOffice",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
setup: func(p libreOffice, logger *zap.Logger) error {
|
||||
return p.Start(logger)
|
||||
},
|
||||
expectHealthy: true,
|
||||
cleanup: true,
|
||||
},
|
||||
{
|
||||
scenario: "LibreOffice not started",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(false)
|
||||
return p
|
||||
}(),
|
||||
expectHealthy: false,
|
||||
cleanup: false,
|
||||
},
|
||||
{
|
||||
scenario: "unhealthy LibreOffice",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(true)
|
||||
p.socketPort = 12345
|
||||
return p
|
||||
}(),
|
||||
expectHealthy: false,
|
||||
cleanup: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
if tc.setup != nil {
|
||||
err := tc.setup(tc.libreOffice, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("setup error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if tc.cleanup {
|
||||
defer func(p libreOffice, logger *zap.Logger) {
|
||||
err := p.Stop(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}(tc.libreOffice, logger)
|
||||
}
|
||||
|
||||
healthy := tc.libreOffice.Healthy(logger)
|
||||
|
||||
if !tc.expectHealthy && healthy {
|
||||
t.Fatal("expected unhealthy LibreOffice but got an healthy one")
|
||||
}
|
||||
|
||||
if tc.expectHealthy && !healthy {
|
||||
t.Fatal("expected a healthy LibreOffice but got an unhealthy one")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeProcess_pdf(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
libreOffice libreOffice
|
||||
fs *gotenberg.FileSystem
|
||||
options Options
|
||||
cancelledCtx bool
|
||||
start bool
|
||||
expectError bool
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
scenario: "LibreOffice not started",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(false)
|
||||
return p
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
cancelledCtx: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "ErrInvalidPdfFormats",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.socketPort = 12345
|
||||
p.isStarted.Store(true)
|
||||
return p
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: "foo"}},
|
||||
cancelledCtx: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
expectedError: ErrInvalidPdfFormats,
|
||||
},
|
||||
{
|
||||
scenario: "ErrMalformedPageRanges",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
options: Options{PageRanges: "foo"},
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("ErrMalformedPageRanges"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: true,
|
||||
expectedError: ErrMalformedPageRanges,
|
||||
},
|
||||
{
|
||||
scenario: "context done",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
cancelledCtx: true,
|
||||
start: true,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success (default options)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (landscape)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{Landscape: true},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (page ranges)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PageRanges: "1-1"},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (PDF/A-1b)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b}},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (PDF/A-2b)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA2b}},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (PDF/A-3b)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA3b}},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (PDF/UA)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PdfFormats: gotenberg.PdfFormats{PdfUa: true}},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
// Force the debug level.
|
||||
logger := zap.NewExample()
|
||||
|
||||
defer func() {
|
||||
err := os.RemoveAll(tc.fs.WorkingDirPath())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if tc.start {
|
||||
err := tc.libreOffice.Start(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("setup error: %v", err)
|
||||
}
|
||||
|
||||
defer func(p libreOffice, logger *zap.Logger) {
|
||||
err = p.Stop(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}(tc.libreOffice, logger)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(5)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if tc.cancelledCtx {
|
||||
cancel()
|
||||
}
|
||||
|
||||
err := tc.libreOffice.pdf(
|
||||
ctx,
|
||||
logger,
|
||||
fmt.Sprintf("%s/document.txt", tc.fs.WorkingDirPath()),
|
||||
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
|
||||
tc.options,
|
||||
)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonBasicLatinCharactersGuard(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
fs *gotenberg.FileSystem
|
||||
filename string
|
||||
expectSameInputPath bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "basic latin characters",
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Basic latin characters"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
filename: "document.txt",
|
||||
expectSameInputPath: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-basic latin characters",
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/éèßàùä.txt", fs.WorkingDirPath()), []byte("Non-basic latin characters"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
filename: "éèßàùä.txt",
|
||||
expectSameInputPath: false,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
defer func() {
|
||||
err := os.RemoveAll(tc.fs.WorkingDirPath())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
inputPath := fmt.Sprintf("%s/%s", tc.fs.WorkingDirPath(), tc.filename)
|
||||
newInputPath, err := nonBasicLatinCharactersGuard(
|
||||
zap.NewNop(),
|
||||
inputPath,
|
||||
)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectSameInputPath && newInputPath != inputPath {
|
||||
t.Fatalf("expected same input path, but got '%s'", newInputPath)
|
||||
}
|
||||
|
||||
if !tc.expectSameInputPath && newInputPath == inputPath {
|
||||
t.Fatalf("expected different input path, but got same '%s'", newInputPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -33,12 +34,21 @@ func (provider *ProviderMock) LibreOffice() (Uno, error) {
|
||||
|
||||
// libreOfficeMock is a mock for the [libreOffice] interface.
|
||||
type libreOfficeMock struct {
|
||||
errCoreDumpedCount int
|
||||
|
||||
gotenberg.ProcessMock
|
||||
pdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
}
|
||||
|
||||
func (b *libreOfficeMock) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
return b.pdfMock(ctx, logger, inputPath, outputPath, options)
|
||||
err := b.pdfMock(ctx, logger, inputPath, outputPath, options)
|
||||
if errors.Is(err, ErrCoreDumped) {
|
||||
b.errCoreDumpedCount += 1
|
||||
}
|
||||
if b.errCoreDumpedCount > 1 {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestApiMock(t *testing.T) {
|
||||
mock := &ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
|
||||
}
|
||||
|
||||
ext := mock.Extensions()
|
||||
if ext != nil {
|
||||
t.Errorf("expected nil result from ApiMock.Extensions, but got: %v", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderMock(t *testing.T) {
|
||||
mock := &ProviderMock{
|
||||
LibreOfficeMock: func() (Uno, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.LibreOffice()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProviderMock.LibreOffice, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeMock(t *testing.T) {
|
||||
mock := &libreOfficeMock{
|
||||
pdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from libreOfficeMock.pdf, but got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ func init() {
|
||||
gotenberg.MustRegisterModule(new(LibreOffice))
|
||||
}
|
||||
|
||||
// LibreOffice is a module which provides a route for converting documents to
|
||||
// LibreOffice is a module that provides a route for converting documents to
|
||||
// PDF with LibreOffice.
|
||||
type LibreOffice struct {
|
||||
api libeofficeapi.Uno
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
package libreoffice
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||
)
|
||||
|
||||
func TestLibreOffice_Descriptor(t *testing.T) {
|
||||
descriptor := new(LibreOffice).Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(LibreOffice))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOffice_Provision(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *gotenberg.Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "no LibreOffice API provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no LibreOffice API from LibreOffice API provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
libreofficeapi.ProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no PDF engine provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
libreofficeapi.ProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
|
||||
return new(libreofficeapi.ApiMock), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no PDF engine from PDF engine provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
libreofficeapi.ProviderMock
|
||||
gotenberg.PdfEngineProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
|
||||
return new(libreofficeapi.ApiMock), nil
|
||||
}
|
||||
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "provision success",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
libreofficeapi.ProviderMock
|
||||
gotenberg.PdfEngineProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
|
||||
return new(libreofficeapi.ApiMock), nil
|
||||
}
|
||||
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
|
||||
return new(gotenberg.PdfEngineMock), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(LibreOffice)
|
||||
err := mod.Provision(tc.ctx)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOffice_Routes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
expectRoutes int
|
||||
disableRoutes bool
|
||||
}{
|
||||
{
|
||||
scenario: "routes not disabled",
|
||||
expectRoutes: 1,
|
||||
disableRoutes: false,
|
||||
},
|
||||
{
|
||||
scenario: "routes disabled",
|
||||
expectRoutes: 0,
|
||||
disableRoutes: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(LibreOffice)
|
||||
mod.disableRoutes = tc.disableRoutes
|
||||
|
||||
routes, err := mod.Routes()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectRoutes != len(routes) {
|
||||
t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// Package pdfengine provides a module which interacts with LibreOffice via the
|
||||
// UNO (Universal Network Objects) API and implements the gotenberg.PdfEngine
|
||||
// interface.
|
||||
// interface. This package allows for:
|
||||
//
|
||||
// 1. The conversion to specific PDF formats.
|
||||
package pdfengine
|
||||
|
||||
@@ -51,14 +51,24 @@ func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logge
|
||||
return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Split is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||
return nil, fmt.Errorf("split PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Flatten is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
|
||||
return fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Convert converts the given PDF to a specific PDF format. Currently, only the
|
||||
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
|
||||
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
|
||||
// error.
|
||||
func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, api.Options{
|
||||
PdfFormats: formats,
|
||||
})
|
||||
opts := api.DefaultOptions()
|
||||
opts.PdfFormats = formats
|
||||
err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, opts)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -71,6 +81,16 @@ func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Log
|
||||
return fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, err)
|
||||
}
|
||||
|
||||
// ReadMetadata is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// WriteMetadata is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
package pdfengine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||
)
|
||||
|
||||
func TestLibreOfficePdfEngine_Descriptor(t *testing.T) {
|
||||
descriptor := new(LibreOfficePdfEngine).Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(LibreOfficePdfEngine))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePdfEngine_Provider(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *gotenberg.Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "no LibreOffice API provider",
|
||||
ctx: gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no API from LibreOffice API provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider := &struct {
|
||||
gotenberg.ModuleMock
|
||||
api.ProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
provider.LibreOfficeMock = func() (api.Uno, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "provision success",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider := &struct {
|
||||
gotenberg.ModuleMock
|
||||
api.ProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
provider.LibreOfficeMock = func() (api.Uno, error) {
|
||||
return new(api.ApiMock), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
engine := new(LibreOfficePdfEngine)
|
||||
err := engine.Provision(tc.ctx)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePdfEngine_Merge(t *testing.T) {
|
||||
engine := new(LibreOfficePdfEngine)
|
||||
err := engine.Merge(context.Background(), zap.NewNop(), nil, "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePdfEngine_Convert(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
api api.Uno
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "convert success",
|
||||
api: &api.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "invalid PDF format",
|
||||
api: &api.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
|
||||
return api.ErrInvalidPdfFormats
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "convert fail",
|
||||
api: &api.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
engine := &LibreOfficePdfEngine{unoApi: tc.api}
|
||||
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,15 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
||||
)
|
||||
|
||||
// convertRoute returns an [api.Route] which can convert LibreOffice documents
|
||||
@@ -21,47 +24,148 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
defaultOptions := libreofficeapi.DefaultOptions()
|
||||
|
||||
form := ctx.FormData()
|
||||
splitMode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
|
||||
zeroValuedSplitMode := gotenberg.SplitMode{}
|
||||
|
||||
// Let's get the data from the form and validate them.
|
||||
var (
|
||||
inputPaths []string
|
||||
landscape bool
|
||||
nativePageRanges string
|
||||
pdfa string
|
||||
pdfua bool
|
||||
nativePdfFormats bool
|
||||
merge bool
|
||||
inputPaths []string
|
||||
password string
|
||||
landscape bool
|
||||
nativePageRanges string
|
||||
updateIndexes bool
|
||||
exportFormFields bool
|
||||
allowDuplicateFieldNames bool
|
||||
exportBookmarks bool
|
||||
exportBookmarksToPdfDestination bool
|
||||
exportPlaceholders bool
|
||||
exportNotes bool
|
||||
exportNotesPages bool
|
||||
exportOnlyNotesPages bool
|
||||
exportNotesInMargin bool
|
||||
convertOooTargetToPdfTarget bool
|
||||
exportLinksRelativeFsys bool
|
||||
exportHiddenSlides bool
|
||||
skipEmptyPages bool
|
||||
addOriginalDocumentAsStream bool
|
||||
singlePageSheets bool
|
||||
losslessImageCompression bool
|
||||
quality int
|
||||
reduceImageResolution bool
|
||||
maxImageResolution int
|
||||
nativePdfFormats bool
|
||||
merge bool
|
||||
flatten bool
|
||||
)
|
||||
|
||||
err := ctx.FormData().
|
||||
err := form.
|
||||
MandatoryPaths(libreOffice.Extensions(), &inputPaths).
|
||||
Bool("landscape", &landscape, false).
|
||||
String("nativePageRanges", &nativePageRanges, "").
|
||||
String("pdfa", &pdfa, "").
|
||||
Bool("pdfua", &pdfua, false).
|
||||
String("password", &password, defaultOptions.Password).
|
||||
Bool("landscape", &landscape, defaultOptions.Landscape).
|
||||
String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges).
|
||||
Bool("updateIndexes", &updateIndexes, defaultOptions.UpdateIndexes).
|
||||
Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields).
|
||||
Bool("allowDuplicateFieldNames", &allowDuplicateFieldNames, defaultOptions.AllowDuplicateFieldNames).
|
||||
Bool("exportBookmarks", &exportBookmarks, defaultOptions.ExportBookmarks).
|
||||
Bool("exportBookmarksToPdfDestination", &exportBookmarksToPdfDestination, defaultOptions.ExportBookmarksToPdfDestination).
|
||||
Bool("exportPlaceholders", &exportPlaceholders, defaultOptions.ExportPlaceholders).
|
||||
Bool("exportNotes", &exportNotes, defaultOptions.ExportNotes).
|
||||
Bool("exportNotesPages", &exportNotesPages, defaultOptions.ExportNotesPages).
|
||||
Bool("exportOnlyNotesPages", &exportOnlyNotesPages, defaultOptions.ExportOnlyNotesPages).
|
||||
Bool("exportNotesInMargin", &exportNotesInMargin, defaultOptions.ExportNotesInMargin).
|
||||
Bool("convertOooTargetToPdfTarget", &convertOooTargetToPdfTarget, defaultOptions.ConvertOooTargetToPdfTarget).
|
||||
Bool("exportLinksRelativeFsys", &exportLinksRelativeFsys, defaultOptions.ExportLinksRelativeFsys).
|
||||
Bool("exportHiddenSlides", &exportHiddenSlides, defaultOptions.ExportHiddenSlides).
|
||||
Bool("skipEmptyPages", &skipEmptyPages, defaultOptions.SkipEmptyPages).
|
||||
Bool("addOriginalDocumentAsStream", &addOriginalDocumentAsStream, defaultOptions.AddOriginalDocumentAsStream).
|
||||
Bool("singlePageSheets", &singlePageSheets, defaultOptions.SinglePageSheets).
|
||||
Bool("losslessImageCompression", &losslessImageCompression, defaultOptions.LosslessImageCompression).
|
||||
Custom("quality", func(value string) error {
|
||||
if value == "" {
|
||||
quality = defaultOptions.Quality
|
||||
return nil
|
||||
}
|
||||
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if intValue < 1 {
|
||||
return errors.New("value is inferior to 1")
|
||||
}
|
||||
|
||||
if intValue > 100 {
|
||||
return errors.New("value is superior to 100")
|
||||
}
|
||||
|
||||
quality = intValue
|
||||
return nil
|
||||
}).
|
||||
Bool("reduceImageResolution", &reduceImageResolution, defaultOptions.ReduceImageResolution).
|
||||
Custom("maxImageResolution", func(value string) error {
|
||||
if value == "" {
|
||||
maxImageResolution = defaultOptions.MaxImageResolution
|
||||
return nil
|
||||
}
|
||||
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.Contains([]int{75, 150, 300, 600, 1200}, intValue) {
|
||||
return errors.New("value is not 75, 150, 300, 600 or 1200")
|
||||
}
|
||||
|
||||
maxImageResolution = intValue
|
||||
return nil
|
||||
}).
|
||||
Bool("nativePdfFormats", &nativePdfFormats, true).
|
||||
Bool("merge", &merge, false).
|
||||
Bool("flatten", &flatten, false).
|
||||
Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
pdfFormats := gotenberg.PdfFormats{
|
||||
PdfA: pdfa,
|
||||
PdfUa: pdfua,
|
||||
}
|
||||
|
||||
// Alright, let's convert each document to PDF.
|
||||
outputPaths := make([]string, len(inputPaths))
|
||||
for i, inputPath := range inputPaths {
|
||||
outputPaths[i] = ctx.GeneratePath(".pdf")
|
||||
|
||||
options := libreofficeapi.Options{
|
||||
Landscape: landscape,
|
||||
PageRanges: nativePageRanges,
|
||||
Password: password,
|
||||
Landscape: landscape,
|
||||
PageRanges: nativePageRanges,
|
||||
UpdateIndexes: updateIndexes,
|
||||
ExportFormFields: exportFormFields,
|
||||
AllowDuplicateFieldNames: allowDuplicateFieldNames,
|
||||
ExportBookmarks: exportBookmarks,
|
||||
ExportBookmarksToPdfDestination: exportBookmarksToPdfDestination,
|
||||
ExportPlaceholders: exportPlaceholders,
|
||||
ExportNotes: exportNotes,
|
||||
ExportNotesPages: exportNotesPages,
|
||||
ExportOnlyNotesPages: exportOnlyNotesPages,
|
||||
ExportNotesInMargin: exportNotesInMargin,
|
||||
ConvertOooTargetToPdfTarget: convertOooTargetToPdfTarget,
|
||||
ExportLinksRelativeFsys: exportLinksRelativeFsys,
|
||||
ExportHiddenSlides: exportHiddenSlides,
|
||||
SkipEmptyPages: skipEmptyPages,
|
||||
AddOriginalDocumentAsStream: addOriginalDocumentAsStream,
|
||||
SinglePageSheets: singlePageSheets,
|
||||
LosslessImageCompression: losslessImageCompression,
|
||||
Quality: quality,
|
||||
ReduceImageResolution: reduceImageResolution,
|
||||
MaxImageResolution: maxImageResolution,
|
||||
}
|
||||
|
||||
if nativePdfFormats {
|
||||
if nativePdfFormats && splitMode == zeroValuedSplitMode {
|
||||
// Only natively apply given PDF formats if we're not
|
||||
// splitting the PDF later.
|
||||
options.PdfFormats = pdfFormats
|
||||
}
|
||||
|
||||
@@ -77,10 +181,17 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, libreofficeapi.ErrMalformedPageRanges) {
|
||||
if errors.Is(err, libreofficeapi.ErrUnoException) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process a document: possible causes include malformed page ranges '%s' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.", options.PageRanges)),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, libreofficeapi.ErrRuntimeException) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain."),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -88,88 +199,84 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
}
|
||||
}
|
||||
|
||||
// So far so good, let's check if we have to merge the PDFs. Quick
|
||||
// win: if there is only one PDF, skip this step.
|
||||
|
||||
if len(outputPaths) > 1 && merge {
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
|
||||
err = engine.Merge(ctx, ctx.Log(), outputPaths, outputPath)
|
||||
if merge {
|
||||
outputPath, err := pdfengines.MergeStub(ctx, engine, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("merge PDFs: %w", err)
|
||||
}
|
||||
|
||||
// Now, let's check if the client want to convert this result
|
||||
// PDF to specific PDF formats.
|
||||
zeroValued := gotenberg.PdfFormats{}
|
||||
if !nativePdfFormats && pdfFormats != zeroValued {
|
||||
convertInputPath := outputPath
|
||||
convertOutputPath := ctx.GeneratePath(".pdf")
|
||||
// Only one output path.
|
||||
outputPaths = []string{outputPath}
|
||||
}
|
||||
|
||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
|
||||
),
|
||||
)
|
||||
if splitMode != zeroValuedSplitMode {
|
||||
if !merge {
|
||||
// document.docx -> document.docx.pdf, so that split naming
|
||||
// document.docx_0.pdf, etc.
|
||||
for i, inputPath := range inputPaths {
|
||||
outputPath := fmt.Sprintf("%s.pdf", inputPath)
|
||||
|
||||
err = ctx.Rename(outputPaths[i], outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename output path: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert PDF: %w", err)
|
||||
outputPaths[i] = outputPath
|
||||
}
|
||||
|
||||
// Important: the output path is now the converted file.
|
||||
outputPath = convertOutputPath
|
||||
}
|
||||
|
||||
// Last but not least, add the output path to the context so that
|
||||
// the Uno is able to send it as a response to the client.
|
||||
|
||||
err = ctx.AddOutputPaths(outputPath)
|
||||
outputPaths, err = pdfengines.SplitPdfStub(ctx, engine, splitMode, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output path: %w", err)
|
||||
return fmt.Errorf("split PDFs: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ok, we don't have to merge the PDFs. Let's check if the client
|
||||
// want to convert each PDF to a specific PDF format.
|
||||
zeroValued := gotenberg.PdfFormats{}
|
||||
if !nativePdfFormats && pdfFormats != zeroValued {
|
||||
convertOutputPaths := make([]string, len(outputPaths))
|
||||
if !nativePdfFormats || (nativePdfFormats && splitMode != zeroValuedSplitMode) {
|
||||
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert PDFs: %w", err)
|
||||
}
|
||||
|
||||
for i, outputPath := range outputPaths {
|
||||
convertInputPath := outputPath
|
||||
convertOutputPaths[i] = ctx.GeneratePath(".pdf")
|
||||
|
||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPaths[i])
|
||||
if err != nil {
|
||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
|
||||
),
|
||||
)
|
||||
if splitMode != zeroValuedSplitMode {
|
||||
// The PDF has been split and split parts have been converted to
|
||||
// specific formats. We want to keep the split naming.
|
||||
for i, convertOutputPath := range convertOutputPaths {
|
||||
err = ctx.Rename(convertOutputPath, outputPaths[i])
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename output path: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
outputPaths = convertOutputPaths
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert PDF: %w", err)
|
||||
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write metadata: %w", err)
|
||||
}
|
||||
|
||||
if flatten {
|
||||
err = pdfengines.FlattenStub(ctx, engine, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("flatten PDFs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(outputPaths) > 1 && splitMode == zeroValuedSplitMode {
|
||||
// If .zip archive, document.docx -> document.docx.pdf.
|
||||
for i, inputPath := range inputPaths {
|
||||
outputPath := fmt.Sprintf("%s.pdf", inputPath)
|
||||
|
||||
err = ctx.Rename(outputPaths[i], outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename output path: %w", err)
|
||||
}
|
||||
|
||||
outputPaths[i] = outputPath
|
||||
}
|
||||
|
||||
// Important: the output paths are now the converted files.
|
||||
outputPaths = convertOutputPaths
|
||||
}
|
||||
|
||||
// Last but not least, add the output paths to the context so that
|
||||
// the Uno is able to send them as a response to the client.
|
||||
|
||||
err = ctx.AddOutputPaths(outputPaths...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output paths: %w", err)
|
||||
|
||||
@@ -1,622 +0,0 @@
|
||||
package libreoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||
)
|
||||
|
||||
func TestConvertRoute(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *api.ContextMock
|
||||
libreOffice libreofficeapi.Uno
|
||||
engine gotenberg.PdfEngine
|
||||
expectOptions libreofficeapi.Options
|
||||
expectError bool
|
||||
expectHttpError bool
|
||||
expectHttpStatus int
|
||||
expectOutputPathsCount int
|
||||
}{
|
||||
{
|
||||
scenario: "missing at least one mandatory file",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
}},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrPdfFormatNotSupported (nativePdfFormats)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return libreofficeapi.ErrInvalidPdfFormats
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrMalformedPageRanges",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfa": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return libreofficeapi.ErrMalformedPageRanges
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "error from LibreOffice",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrPdfFormatNotSupported (single file)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfa": {
|
||||
"foo",
|
||||
},
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return gotenberg.ErrPdfFormatNotSupported
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "PDF engine convert error (single file)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "cannot add output paths (single file)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetCancelled(true)
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "success (single file)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
scenario: "success (many files)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 2,
|
||||
},
|
||||
{
|
||||
scenario: "success with non-native PDF/A & PDF/UA (single file)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
"pdfua": {
|
||||
"true",
|
||||
},
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
scenario: "success with native PDF/A & PDF/UA (single file)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
"pdfua": {
|
||||
"true",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
scenario: "merge error",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrPdfFormatNotSupported (merge)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
"pdfa": {
|
||||
"foo",
|
||||
},
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return gotenberg.ErrPdfFormatNotSupported
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "PDF engine convert error (merge)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "cannot add output paths (merge)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
})
|
||||
ctx.SetCancelled(true)
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "success (merge)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
scenario: "success with non-native PDF/A & PDF/UA (merge)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
"pdfua": {
|
||||
"true",
|
||||
},
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
scenario: "success with non-native PDF/A & PDF/UA (merge)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
"pdfua": {
|
||||
"true",
|
||||
},
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
tc.ctx.SetLogger(zap.NewNop())
|
||||
c := echo.New().NewContext(nil, nil)
|
||||
c.Set("context", tc.ctx.Context)
|
||||
|
||||
err := convertRoute(tc.libreOffice, tc.engine).Handler(c)
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none", err)
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
var httpErr api.HttpError
|
||||
isHttpError := errors.As(err, &httpErr)
|
||||
|
||||
if tc.expectHttpError && !isHttpError {
|
||||
t.Errorf("expected an HTTP error but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectHttpError && isHttpError {
|
||||
t.Errorf("expected no HTTP error but got one: %v", httpErr)
|
||||
}
|
||||
|
||||
if err != nil && tc.expectHttpError && isHttpError {
|
||||
status, _ := httpErr.HttpError()
|
||||
if status != tc.expectHttpStatus {
|
||||
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
|
||||
}
|
||||
}
|
||||
|
||||
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
|
||||
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
49
pkg/modules/logging/color.go
Normal file
49
pkg/modules/logging/color.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// Foreground colors.
|
||||
// Copy pasted from go.uber.org/zap/internal/color/color.go
|
||||
const (
|
||||
black color = iota + 30
|
||||
red
|
||||
green
|
||||
yellow
|
||||
blue
|
||||
magenta
|
||||
cyan
|
||||
white
|
||||
)
|
||||
|
||||
type color uint8
|
||||
|
||||
func (c color) Add(s string) string {
|
||||
return fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(c), s)
|
||||
}
|
||||
|
||||
func levelToColor(l zapcore.Level) color {
|
||||
switch l {
|
||||
case zapcore.DebugLevel:
|
||||
return cyan
|
||||
case zapcore.InfoLevel:
|
||||
return blue
|
||||
case zapcore.WarnLevel:
|
||||
return yellow
|
||||
case zapcore.ErrorLevel:
|
||||
return red
|
||||
case zapcore.DPanicLevel:
|
||||
return red
|
||||
case zapcore.PanicLevel:
|
||||
return red
|
||||
case zapcore.FatalLevel:
|
||||
return red
|
||||
case zapcore.InvalidLevel:
|
||||
return red
|
||||
default:
|
||||
return red
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user