Compare commits

...

63 Commits

Author SHA1 Message Date
Julien Neuhart
16e4c10862 chore(deps): update Go dependencies 2025-03-05 08:57:48 +01:00
Julien Neuhart
904e0f61a8 fix(pdfengines): split and read metadata pdf engines calls 2025-03-04 18:08:57 +01:00
Zdravko
70953aa66e fix(qpdf): allow warnings (#1135)
* Optionally allow warnings in QPDF operations

* warnings are not errors by default

* Apply suggestions from code review

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>

* fix some merge artifacts

* follow the args convention

---------

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>
2025-02-28 10:43:38 +01:00
Julien Neuhart
d41de5d270 docs(README): switch to new short description [skip ci] 2025-02-21 11:55:12 +01:00
Julien Neuhart
d8603f4f34 feat: update short description 2025-02-21 11:22:22 +01:00
Julien Neuhart
b13a8094ea ci(continous-delivery): remove wrong outputs 2025-02-19 19:42:30 +01:00
Kyohei Fukuda
28e07bad54 docs(README): add pdfme sponsor (#1131) 2025-02-14 14:28:53 +01:00
Julien Neuhart
81cc483166 test: fix issues with go 1.24 2025-02-13 09:21:38 +01:00
Julien Neuhart
8d7cc55c28 chore(go): upgrade to 1.24 2025-02-13 08:44:58 +01:00
Julien Neuhart
083e0e307a chore(deps): update Go dependencies 2025-02-12 16:24:16 +01:00
Julien Neuhart
80f3f89a89 fix(pdfcpu): correct sorting for the output paths of the split method 2025-02-12 11:47:45 +01:00
Julien Neuhart
3dc8c18849 fix(fs): get file by ext sorted by mod time 2025-02-11 17:00:18 +01:00
Julien Neuhart
0a6a97cf07 ci(merge): fix thecodingmachine target 2025-02-07 09:59:08 +01:00
Julien Neuhart
7676d7a239 ci(build-push): fix typo in tag 2025-02-07 09:47:47 +01:00
Julien Neuhart
679715f896 chore(deps): update Go dependencies 2025-02-07 08:43:44 +01:00
Julien Neuhart
d5aa880336 ci(build-push): fix typo in tag name 2025-02-07 08:41:04 +01:00
Julien Neuhart
08daa86766 test(api): add missing context 2025-02-06 09:07:09 +01:00
Julien Neuhart
f0c1952dfe ci(lint): fix missing error handling 2025-02-06 08:51:38 +01:00
Julien Neuhart
198d9115cc chore(deps): switch to github.com/mholt/archives 2025-02-05 17:00:48 +01:00
Julien Neuhart
6b0c01bd3e chore(deps): update Go dependencies 2025-02-05 09:51:58 +01:00
Julien Neuhart
a1848d3c75 feat(conf): override flags' values with their corresponding environment variables' values 2025-02-05 09:51:38 +01:00
Julien Neuhart
a1596f7c62 feat(api): add debug route 2025-02-04 10:20:29 +01:00
Julien Neuhart
740e701ad3 chore(deps): update Go dependencies 2025-02-03 11:53:51 +01:00
Julien Neuhart
71d571a25c fix(Makefile): wrong arg for --pdfengines-convert-engines 2025-02-03 11:36:05 +01:00
Julien Neuhart
2293300182 ci(merge): fix alternate registry target image #2 2025-01-31 15:58:06 +01:00
Julien Neuhart
85c7f2806f ci(merge): fix alternate registry target image 2025-01-31 15:38:14 +01:00
dependabot[bot]
27ecb4fe49 chore(deps): bump codecov/codecov-action from 4 to 5
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4 to 5.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-31 14:43:21 +01:00
Julien Neuhart
7afeefe84f ci(actions): add local actions 2025-01-31 14:39:54 +01:00
Julien Neuhart
651922194f chore(deps): update Go dependencies 2025-01-29 20:48:43 +01:00
Julien Neuhart
1d1b4fb04d feat(pdfengines): add flatten form field to split route 2025-01-28 17:15:14 +01:00
Peter Chakalov
b418f1eb05 feat(pdfengines): add support for flattening annotations (#1105)
* initial changes

* Add tests

* Fix edge case when we need to regenerate appearances

* Fix comments

* Add missing comment

* Add missing comment

* Add missing comment

* Add flatten option to the merge route

* Add flatten option to the libreoffice convert route

* Add flatten option to the chromium convert route

* Revert "Add flatten option to the chromium convert route"

This reverts commit cdab8b4e6b.

* Ignore lint false positives

* Add missing tests

* Add flatten route tests

* Replace input instead of creating a new file

* create copy before flatten in tests

---------

Co-authored-by: Peter Chakalov <peter.chakalov@abraxa.com>
2025-01-28 14:38:27 +01:00
Julien Neuhart
a9b44ee39e chore(lint): remove rule G115 (gosec) exception 2025-01-27 16:05:52 +01:00
Julien Neuhart
cb48355898 fix(ci): missing checkout step on reusable workflow delete 2025-01-24 10:46:31 +01:00
Julien Neuhart
6ce47e3a20 ci(workflows): cleanup PR Docker images 2025-01-24 10:27:04 +01:00
Julien Neuhart
09e511bc49 ci(workflows): use sub-actions 2025-01-24 10:27:04 +01:00
Julien Neuhart
66ade27383 docs(README): fix continuous integration badge 2025-01-23 20:18:18 +01:00
Julien Neuhart
d96058d589 fix(Dockerfile): use latest Chromium for armhf 2025-01-23 20:04:13 +01:00
Julien Neuhart
8631a15933 ci(comment): remove comment about arm64 testing 2025-01-23 20:04:13 +01:00
Julien Neuhart
c0bf81ad7c ci(workflows): multiple jobs multiple architectures build and push 2025-01-23 18:03:23 +01:00
Julien Neuhart
8fd1fa203e fix(Dockerfile): remove apt-get upgrade -yqq from armv7 Chromium install 2025-01-17 18:01:44 +01:00
Julien Neuhart
51b907bcf5 chore(deps): update Go dependencies 2025-01-17 15:25:17 +01:00
Julien Neuhart
ecb5d974c5 chore(Dockerfile): add apt-get upgrade -yqq 2025-01-17 15:24:37 +01:00
Julien Neuhart
19c91aff19 feat(api): add root, favicon and version routes to basic auth middleware if enabled 2025-01-17 14:08:01 +01:00
Julien Neuhart
8e0cc7dd2f fix(typo): root route HTML 2025-01-17 10:00:08 +01:00
Khiet Tam Nguyen
6478528ebc feat(api): add dummy root route pointing to docs (#1099)
* feat(api): added dummy root route pointing to docs

* test(api): root request test added

* feat(api): added NoContent return for favicon

from @gulien

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>

* test(api): added favicon

* lint(api): fix format

* refactor(api): use %s for favicon.ico for consistency with /health

* test(api): added new recorder for each check

* docs(api): consistent comment for favicon

---------

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>
2025-01-17 09:58:58 +01:00
Julien Neuhart
3e3b24f611 ci(workflows): add permissions 2025-01-11 11:28:27 +01:00
Julien Neuhart
6dc7d70a1f ci(continous integration): add concurrency to avoid concurrent runs 2025-01-11 11:17:50 +01:00
Julien Neuhart
a6f4c5651c feat(chromium): set the default value of the flag --chromium-restart-after to 10 2025-01-11 11:08:19 +01:00
Julien Neuhart
291a4d39fe chore(deps): upgrade to golangci-lint v1.63.4 2025-01-10 17:02:16 +01:00
Julien Neuhart
e639a1dd73 chore(deps): update Go dependencies 2025-01-10 16:59:01 +01:00
Julien Neuhart
e3a961623b feat(logging): add new field log_type with access or application as values 2025-01-10 16:58:26 +01:00
Hugo Tacla
1ca27fdba2 fix(pdfengines): wrong variable name (#1093) 2025-01-08 19:24:43 +01:00
Julien Neuhart
f72428056a chore(code): fmt 2025-01-08 17:15:30 +01:00
Hugo Tacla
bdb3f11ac7 fix(typo): medata -> metadata (#1092)
* Update pdfengines.go

* Update pdfengines_test.go

* Update multi.go

* Update multi_test.go
2025-01-08 17:14:53 +01:00
Julien Neuhart
2f69af1a52 chore(LICENSE): remove year 2025-01-06 09:17:13 +01:00
Julien Neuhart
984cf77ee0 fix(chromium): info log into debug log + slight improvement of the killing of stuck processes 2025-01-02 17:22:13 +01:00
Julien Neuhart
1762596fce chore(dep)s: update dependencies 2025-01-02 17:18:53 +01:00
Julien Neuhart
910eb9b770 feat(state): improve clean up when LibreOffice and Chromium are restarted 2024-12-25 17:04:25 +01:00
Julien Neuhart
16807bd57f fix(split): wrong output paths when converting to PDF/A & PDF/UA 2024-12-23 10:04:24 +01:00
Julien Neuhart
8bc29ad92d feat(split): add splitUnify form field 2024-12-21 12:45:20 +01:00
Julien Neuhart
51a913a5e4 chore(deps): update Go depencies 2024-12-20 16:16:32 +01:00
Julien Neuhart
c30da805b3 feat(pdfengines): add split feature 2024-12-20 16:16:32 +01:00
Julien Neuhart
42ee593708 chore(pdfengines): add stubs for common workflows 2024-11-25 09:28:42 +01:00
77 changed files with 5629 additions and 1172 deletions

14
.env Normal file
View File

@@ -0,0 +1,14 @@
GOLANG_VERSION=1.24
DOCKER_REGISTRY=gotenberg
DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
GOLANGCI_LINT_VERSION=v1.64.2 # See https://github.com/golangci/golangci-lint/releases.
GOTENBERG_VERSION=snapshot
DOCKERFILE=build/Dockerfile
DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun
DOCKER_BUILD_CONTEXT='.'

67
.github/actions/build-push/action.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Build and Push
description: Build and push Docker images
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
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_push.outputs.tags }}
tags_cloud_run:
description: Comma separated list of Cloud Run tags (linux/amd64 only)
value: ${{ steps.build_push.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
uses: docker/login-action@v3
with:
username: ${{ inputs.docker_hub_username }}
password: ${{ inputs.docker_hub_password }}
- name: Build and push ${{ inputs.platform }}
id: build_push
shell: bash
run: |
.github/actions/build-push/build-push.sh \
--version "${{ inputs.version }}" \
--platform "${{ inputs.platform }}" \
--alternate-repository "${{ inputs.alternate_repository }}" \
--dry-run "${{ inputs.dry_run }}"
- name: Outputs
shell: bash
run: |
echo "tags=${{ steps.build_push.outputs.tags }}"
echo "tags_cloud_run=${{ steps.build_push.outputs.tags_cloud_run }}"

173
.github/actions/build-push/build-push.sh vendored Executable file
View File

@@ -0,0 +1,173 @@
#!/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 push 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 and push 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 "$*"
}
cmd="docker buildx build \
--build-arg GOLANG_VERSION=$GOLANG_VERSION \
--build-arg GOTENBERG_VERSION=$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 \
--build-arg PDFCPU_VERSION=$PDFCPU_VERSION \
--push \
--platform $platform \
${tags_flags[*]} \
-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
source_tag_cloud_run="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-${arch[1]}"
cmd="docker pull $source_tag_cloud_run"
run_cmd "$cmd"
target_tag_cloud_run="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
cmd="docker image tag $source_tag_cloud_run $target_tag_cloud_run"
run_cmd "$cmd"
cmd="docker build \
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
--build-arg GOTENBERG_VERSION=$version \
--push \
${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

31
.github/actions/clean/action.yml vendored Normal file
View 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
View 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
View 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 }}"

109
.github/actions/merge/merge.sh vendored Executable file
View File

@@ -0,0 +1,109 @@
#!/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

View File

@@ -1,8 +1,7 @@
version: 2 version: 2
updates: updates:
# Maintain dependencies for GitHub Actions # Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"

View File

@@ -0,0 +1,112 @@
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-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ github.event.release.tag_name }}
platform: linux/amd64
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-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ github.event.release.tag_name }}
platform: linux/386
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-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ github.event.release.tag_name }}
platform: linux/arm64
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-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ github.event.release.tag_name }}
platform: linux/arm/v7
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 }}"

View File

@@ -0,0 +1,295 @@
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
runs-on: ubuntu-latest
steps:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
cache: false
- name: Checkout source code
uses: actions/checkout@v4
- name: Run linters
uses: golangci/golangci-lint-action@v6
with:
version: v1.64.2
tests:
needs:
- lint
name: Tests
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
- name: Upload to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true
snapshot_amd64:
if: github.event_name == 'pull_request'
needs:
- tests
name: Snapshot 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-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:
- tests
name: Snapshot 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-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:
- tests
name: Snapshot 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-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:
- tests
name: Snapshot 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-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_tags:
needs:
- 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:
- tests
name: Edge 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-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:
- tests
name: Edge 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-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:
- tests
name: Edge 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-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:
- tests
name: Edge 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-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 }}"

View File

@@ -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_REGISTRY=thecodingmachine

View File

@@ -1,95 +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.23'
cache: false
- name: Checkout source code
uses: actions/checkout@v4
- name: Run linters
uses: golangci/golangci-lint-action@v6
with:
version: v1.61.0
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
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true
snapshot_release:
if: github.event_name == 'pull_request'
needs:
- tests
name: Snapshot release
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 snapshot Docker image (linux/amd64)
run: make release GOTENBERG_VERSION=${{ github.head_ref }} DOCKER_REPOSITORY=snapshot LINUX_AMD64_RELEASE=true
edge_release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
name: Edge release
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_REGISTRY=thecodingmachine

View File

@@ -0,0 +1,23 @@
name: Pull Request Cleanup
on:
pull_request:
types: [closed]
permissions:
contents: read
jobs:
cleanup:
name: Cleanup Docker images
runs-on: ubuntu-latest
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 }}

View File

@@ -6,10 +6,6 @@ linters-settings:
- prefix(github.com/gotenberg/gotenberg/v8) - prefix(github.com/gotenberg/gotenberg/v8)
skip-generated: true skip-generated: true
custom-order: true custom-order: true
# Until https://github.com/securego/gosec/issues/1187 is resolved.
gosec:
excludes:
- G115
linters: linters:
disable-all: true disable-all: true
@@ -41,12 +37,12 @@ linters:
- promlinter - promlinter
#- sloglint #- sloglint
- staticcheck - staticcheck
- tenv
- testableexamples - testableexamples
- tparallel - tparallel
- typecheck - typecheck
- unconvert - unconvert
- unused - unused
- usetesting
- wastedassign - wastedassign
- whitespace - whitespace

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2024 Julien Neuhart Copyright (c) Julien Neuhart
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,3 +1,5 @@
include .env
.PHONY: help .PHONY: help
help: ## Show the 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}' @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@@ -5,17 +7,6 @@ help: ## Show the help
.PHONY: it .PHONY: it
it: build build-tests ## Initialize the development environment it: build build-tests ## Initialize the development environment
GOLANG_VERSION=1.23
DOCKER_REGISTRY=gotenberg
DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
GOLANGCI_LINT_VERSION=v1.61.0 # See https://github.com/golangci/golangci-lint/releases.
.PHONY: build .PHONY: build
build: ## Build the Gotenberg's Docker image build: ## Build the Gotenberg's Docker image
docker build \ docker build \
@@ -27,7 +18,7 @@ build: ## Build the Gotenberg's Docker image
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \ --build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
--build-arg PDFCPU_VERSION=$(PDFCPU_VERSION) \ --build-arg PDFCPU_VERSION=$(PDFCPU_VERSION) \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \ -t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
-f build/Dockerfile . -f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000 API_PORT=3000
@@ -36,7 +27,7 @@ API_BIND_IP=
API_START_TIMEOUT=30s API_START_TIMEOUT=30s
API_TIMEOUT=30s API_TIMEOUT=30s
API_BODY_LIMIT= API_BODY_LIMIT=
API_ROOT_PATH=/ API_ROOT_PATH="/"
API_TRACE_HEADER=Gotenberg-Trace API_TRACE_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME= GOTENBERG_API_BASIC_AUTH_USERNAME=
@@ -46,7 +37,8 @@ API-DOWNLOAD-FROM-DENY-LIST=
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4 API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
API-DISABLE-DOWNLOAD-FROM=false API-DISABLE-DOWNLOAD-FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=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_MAX_QUEUE_SIZE=0
CHROMIUM_AUTO_START=false CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s CHROMIUM_START_TIMEOUT=20s
@@ -73,6 +65,8 @@ LOG_FORMAT=auto
LOG_FIELDS_PREFIX= LOG_FIELDS_PREFIX=
PDFENGINES_ENGINES= PDFENGINES_ENGINES=
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
PDFENGINES_FLATTEN_ENGINES=qpdf
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
PDFENGINES_READ_METADATA_ENGINES=exiftool PDFENGINES_READ_METADATA_ENGINES=exiftool
PDFENGINES_WRITE_METADATA_ENGINES=exiftool PDFENGINES_WRITE_METADATA_ENGINES=exiftool
@@ -114,6 +108,7 @@ run: ## Start a Gotenberg container
--api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \ --api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \
--api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \ --api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \ --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-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \ --chromium-auto-start=$(CHROMIUM_AUTO_START) \
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \ --chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
@@ -141,6 +136,8 @@ run: ## Start a Gotenberg container
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \ --log-fields-prefix=$(LOG_FIELDS_PREFIX) \
--pdfengines-engines=$(PDFENGINES_ENGINES) \ --pdfengines-engines=$(PDFENGINES_ENGINES) \
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \ --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-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \ --pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \ --pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
@@ -197,20 +194,3 @@ fmt: ## Format the code and "optimize" the dependencies
godoc: ## Run a webserver with Gotenberg godoc godoc: ## Run a webserver with Gotenberg godoc
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8) $(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8)
godoc -http=:6060 godoc -http=:6060
LINUX_AMD64_RELEASE=false
.PHONY: release
release: ## Build the Gotenberg's Docker image and 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) \
$(PDFCPU_VERSION) \
$(DOCKER_REGISTRY) \
$(DOCKER_REPOSITORY) \
$(LINUX_AMD64_RELEASE)

View File

@@ -1,12 +1,12 @@
<p align="center"> <p align="center">
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" /> <img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
<h3 align="center">Gotenberg</h3> <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"> <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/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://hub.docker.com/r/thecodingmachine/gotenberg"><img alt="Total downloads (thecodingmachine/gotenberg)" src="https://img.shields.io/docker/pulls/thecodingmachine/gotenberg"></a>
<br> <br>
<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://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> <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>
<a href="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"><img alt="Code coverage" src="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"></a> <a href="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"><img alt="Code coverage" src="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"></a>
</p> </p>
@@ -45,6 +45,9 @@ Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduct
<a href="https://zolsec.com?utm_source=gotenberg_github&utm_medium=website" target="_blank"> <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" /> <img src="https://github.com/gotenberg/gotenberg/assets/8983173/707ccc97-a79b-4dcb-8fc8-6827366e5be3" alt="Zolsec Logo" width="333" height="163" />
</a> </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> </p>
Sponsorships help maintaining and improving Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️ Sponsorships help maintaining and improving Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️

View File

@@ -61,10 +61,9 @@ ARG GOTENBERG_USER_GID
ARG GOTENBERG_USER_UID ARG GOTENBERG_USER_UID
ARG NOTO_COLOR_EMOJI_VERSION ARG NOTO_COLOR_EMOJI_VERSION
ARG PDFTK_VERSION ARG PDFTK_VERSION
ARG TMP_CHOMIUM_VERSION_ARMHF="116.0.5845.180-1~deb12u1"
LABEL org.opencontainers.image.title="Gotenberg" \ LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.description="A Docker-powered stateless API for PDF files." \ org.opencontainers.image.description="A containerized API for seamless PDF conversion." \
org.opencontainers.image.version="$GOTENBERG_VERSION" \ org.opencontainers.image.version="$GOTENBERG_VERSION" \
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \ org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
org.opencontainers.image.documentation="https://gotenberg.dev" \ org.opencontainers.image.documentation="https://gotenberg.dev" \
@@ -82,6 +81,7 @@ RUN \
# Install system dependencies required for the next instructions or debugging. # Install system dependencies required for the next instructions or debugging.
# Note: tini is a helper for reaping zombie processes. # Note: tini is a helper for reaping zombie processes.
apt-get update -qq &&\ apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 default-jre-headless &&\ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 default-jre-headless &&\
# Cleanup. # Cleanup.
# Note: the Debian image does automatically a clean after each install thanks to a hook. # Note: the Debian image does automatically a clean after each install thanks to a hook.
@@ -96,6 +96,7 @@ RUN \
# https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html. # 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 &&\ 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 update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
./ttf-mscorefonts-installer_3.8.1_all.deb \ ./ttf-mscorefonts-installer_3.8.1_all.deb \
culmus \ culmus \
@@ -145,27 +146,18 @@ RUN \
# Install either Google Chrome stable on amd64 architecture or # Install either Google Chrome stable on amd64 architecture or
# Chromium on other architectures. # Chromium on other architectures.
# See https://github.com/gotenberg/gotenberg/issues/328. # 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 \ /bin/bash -c \
'set -e &&\ 'set -e &&\
if [[ "$(dpkg --print-architecture)" == "amd64" ]]; then \ if [[ "$(dpkg --print-architecture)" == "amd64" ]]; then \
curl https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\ 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 &&\ 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 update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\ 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; \ 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/*; \
else \ else \
apt-get update -qq &&\ apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' &&\ fi' &&\
# Verify installation. # Verify installation.
@@ -177,6 +169,7 @@ RUN \
# Install LibreOffice & unoconverter. # Install LibreOffice & unoconverter.
echo "deb http://deb.debian.org/debian bookworm-backports main" >> /etc/apt/sources.list &&\ echo "deb http://deb.debian.org/debian bookworm-backports main" >> /etc/apt/sources.list &&\
apt-get update -qq &&\ apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t bookworm-backports libreoffice &&\ 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.0.1/unoconv -o /usr/bin/unoconverter &&\
chmod +x /usr/bin/unoconverter &&\ chmod +x /usr/bin/unoconverter &&\
@@ -196,6 +189,7 @@ RUN \
echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \ echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
chmod +x /usr/bin/pdftk &&\ chmod +x /usr/bin/pdftk &&\
apt-get update -qq &&\ apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\
# See https://github.com/nextcloud/docker/issues/380. # See https://github.com/nextcloud/docker/issues/380.
mkdir -p /usr/share/man/man1 &&\ mkdir -p /usr/share/man/man1 &&\

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time" "time"
@@ -23,7 +24,7 @@ const banner = `
\___/\___/\__/\__/_//_/_.__/\__/_/ \_, / \___/\___/\__/\__/_//_/_.__/\__/_/ \_, /
/___/ /___/
A Docker-powered stateless API for PDF files. A containerized API for seamless PDF conversion.
Version: %s Version: %s
------------------------------------------------------- -------------------------------------------------------
` `
@@ -50,14 +51,42 @@ func Run() {
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo) fmt.Printf("[SYSTEM] modules: %s\n", modsInfo)
// Parse the flags... // Parse the flags.
err := fs.Parse(os.Args[1:]) err := fs.Parse(os.Args[1:])
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) 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} parsedFlags := gotenberg.ParsedFlags{FlagSet: fs}
// Get the graceful shutdown duration. // Get the graceful shutdown duration.
@@ -108,6 +137,9 @@ func Run() {
}(l.(gotenberg.SystemLogger)) }(l.(gotenberg.SystemLogger))
} }
// Build the debug data.
gotenberg.BuildDebug(ctx)
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) or SIGTERM (Kubernetes). // We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) or SIGTERM (Kubernetes).

70
go.mod
View File

@@ -1,63 +1,79 @@
module github.com/gotenberg/gotenberg/v8 module github.com/gotenberg/gotenberg/v8
go 1.23.0 go 1.24.0
require ( require (
github.com/alexliesenfeld/health v0.8.0 github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect
github.com/barasher/go-exiftool v1.10.0 github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b github.com/chromedp/cdproto v0.0.0-20250304222902-64be311ed8b0
github.com/chromedp/chromedp v0.11.2 github.com/chromedp/chromedp v0.13.0
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hashicorp/go-retryablehttp v0.7.7
github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/echo/v4 v4.12.0 github.com/labstack/echo/v4 v4.13.3
github.com/labstack/gommon v0.4.2 github.com/labstack/gommon v0.4.2
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/nwaples/rardecode v1.1.3 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/prometheus/client_golang v1.21.1
github.com/prometheus/client_golang v1.20.5
github.com/russross/blackfriday/v2 v2.1.0 github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.6
github.com/ulikunitz/xz v0.5.12 // indirect github.com/ulikunitz/xz v0.5.12 // indirect
go.uber.org/multierr v1.11.0 go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.29.0 // indirect golang.org/x/crypto v0.35.0 // indirect
golang.org/x/net v0.31.0 golang.org/x/net v0.36.0
golang.org/x/sync v0.9.0 golang.org/x/sync v0.11.0
golang.org/x/sys v0.27.0 // indirect golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.26.0 golang.org/x/term v0.29.0
golang.org/x/text v0.20.0 golang.org/x/text v0.22.0
) )
require github.com/dlclark/regexp2 v1.11.4 require (
github.com/dlclark/regexp2 v1.11.5
github.com/mholt/archives v0.1.0
github.com/shirou/gopsutil/v4 v4.25.2
)
require ( require (
github.com/STARRY-S/zip v0.2.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.0 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect github.com/gobwas/ws v1.4.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/lufia/plan9stats v0.0.0-20250303091104-876f3ea5145d // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nwaples/rardecode/v2 v2.1.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.60.1 // indirect github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect
github.com/sorairolake/lzip-go v0.3.5 // indirect
github.com/therootcompany/xz v1.0.1 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.9.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/time v0.8.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect
google.golang.org/protobuf v1.35.2 // indirect golang.org/x/time v0.10.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
) )

390
go.sum
View File

@@ -1,6 +1,26 @@
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=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
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/STARRY-S/zip v0.2.2 h1:8QeCbIi1Z9U5MgoDARJR1ClbBo9RD46SmVy+dl0woCk=
github.com/STARRY-S/zip v0.2.2/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk= 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/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.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -9,143 +29,383 @@ github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEX
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo= 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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b h1:md1Gk5jkNE91SZxFDCMHmKqX0/GsEr1/VTejht0sCbY= github.com/chromedp/cdproto v0.0.0-20250304222902-64be311ed8b0 h1:eyqx6IwUz4gxJUU5CKiZsuGdNPVRCTWQQN5LpS1T5eo=
github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM= github.com/chromedp/cdproto v0.0.0-20250304222902-64be311ed8b0/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0= github.com/chromedp/chromedp v0.13.0 h1:ydOqt7Y9LkwgutrX5C8bx49D+o63L6WcGUDyIoE0A5M=
github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8= github.com/chromedp/chromedp v0.13.0/go.mod h1:O3nO4Lno7iLoVX+7GdqQkehhKG7DtLf/zFRyJo0AhXY=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= 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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/ebitengine/purego v0.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.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/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-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 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 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 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 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.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/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 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-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 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
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/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/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.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= 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 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 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 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/lufia/plan9stats v0.0.0-20250303091104-876f3ea5145d h1:fjMbDVUGsMQiVZnSQsmouYJvMdwsGiDipOZoN66v844=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/lufia/plan9stats v0.0.0-20250303091104-876f3ea5145d/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= github.com/mholt/archives v0.1.0 h1:FacgJyrjiuyomTuNA92X5GyRBRZjE43Y/lrzKIlF35Q=
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= github.com/mholt/archives v0.1.0/go.mod h1:j/Ire/jm42GN7h90F5kzj6hf6ZFzEH66de+hmjEKu+I=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode/v2 v2.1.0 h1:JQl9ZoBPDy+nIZGb1mx8+anfHp/LV3NE2MjMiv0ct/U=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= github.com/nwaples/rardecode/v2 v2.1.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc= github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 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/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/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk=
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
github.com/sorairolake/lzip-go v0.3.5 h1:ms5Xri9o1JBIWvOFAorYtUNik6HI3HgBTkISiqu0Cwg=
github.com/sorairolake/lzip-go v0.3.5/go.mod h1:N0KYq5iWrMXI0ZEXKXaS9hCyOjZUQdBDEIbXfoUwbdk=
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.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 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 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=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 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/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
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.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-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.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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-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-20220520151302-bc2c85ada10a/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
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.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
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.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= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 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/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/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=

68
pkg/gotenberg/debug.go Normal file
View 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
)

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

View File

@@ -7,18 +7,50 @@ import (
"github.com/google/uuid" "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
// purpose.
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 // FileSystem provides utilities for managing temporary directories. It creates
// unique directory names based on UUIDs to ensure isolation of temporary files // unique directory names based on UUIDs to ensure isolation of temporary files
// for different modules. // for different modules.
type FileSystem struct { type FileSystem struct {
workingDir string workingDir string
mkdirAll MkdirAll
} }
// NewFileSystem initializes a new [FileSystem] instance with a unique working // NewFileSystem initializes a new [FileSystem] instance with a unique working
// directory. // directory.
func NewFileSystem() *FileSystem { func NewFileSystem(mkdirAll MkdirAll) *FileSystem {
return &FileSystem{ return &FileSystem{
workingDir: uuid.NewString(), workingDir: uuid.NewString(),
mkdirAll: mkdirAll,
} }
} }
@@ -44,7 +76,7 @@ func (fs *FileSystem) NewDirPath() string {
func (fs *FileSystem) MkdirAll() (string, error) { func (fs *FileSystem) MkdirAll() (string, error) {
path := fs.NewDirPath() path := fs.NewDirPath()
err := os.MkdirAll(path, 0o755) err := fs.mkdirAll.MkdirAll(path, 0o755)
if err != nil { if err != nil {
return "", fmt.Errorf("create directory %s: %w", path, err) return "", fmt.Errorf("create directory %s: %w", path, err)
} }
@@ -52,10 +84,8 @@ func (fs *FileSystem) MkdirAll() (string, error) {
return path, nil return path, nil
} }
// PathRename defines the method signature for renaming files. Implement this // Interface guards.
// interface if you don't want to rely on [os.Rename], notably for testing var (
// purpose. _ MkdirAll = (*OsMkdirAll)(nil)
type PathRename interface { _ PathRename = (*OsPathRename)(nil)
// Rename uses the same signature as [os.Rename]. )
Rename(oldpath, newpath string) error
}

View File

@@ -1,14 +1,83 @@
package gotenberg package gotenberg
import ( import (
"errors"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/google/uuid"
) )
func TestOsMkdirAll_MkdirAll(t *testing.T) {
dirPath, err := NewFileSystem(new(OsMkdirAll)).MkdirAll()
if err != nil {
t.Fatalf("create working directory: %v", err)
}
err = os.RemoveAll(dirPath)
if err != nil {
t.Fatalf("remove working directory: %v", err)
}
}
func TestOsPathRename_Rename(t *testing.T) {
dirPath, err := NewFileSystem(new(OsMkdirAll)).MkdirAll()
if err != nil {
t.Fatalf("create working directory: %v", err)
}
path := "/tests/test/testdata/api/sample1.txt"
copyPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
in, err := os.Open(path)
if err != nil {
t.Fatalf("open file: %v", err)
}
defer func() {
err := in.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}()
out, err := os.Create(copyPath)
if err != nil {
t.Fatalf("create new file: %v", err)
}
defer func() {
err := out.Close()
if err != nil {
t.Fatalf("close new file: %v", err)
}
}()
_, err = io.Copy(out, in)
if err != nil {
t.Fatalf("copy file to new file: %v", err)
}
rename := new(OsPathRename)
newPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
err = rename.Rename(copyPath, newPath)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
err = os.RemoveAll(dirPath)
if err != nil {
t.Fatalf("remove working directory: %v", err)
}
}
func TestFileSystem_WorkingDir(t *testing.T) { func TestFileSystem_WorkingDir(t *testing.T) {
fs := NewFileSystem() fs := NewFileSystem(new(MkdirAllMock))
dirName := fs.WorkingDir() dirName := fs.WorkingDir()
if dirName == "" { if dirName == "" {
@@ -17,7 +86,7 @@ func TestFileSystem_WorkingDir(t *testing.T) {
} }
func TestFileSystem_WorkingDirPath(t *testing.T) { func TestFileSystem_WorkingDirPath(t *testing.T) {
fs := NewFileSystem() fs := NewFileSystem(new(MkdirAllMock))
expectedPath := fmt.Sprintf("%s/%s", os.TempDir(), fs.WorkingDir()) expectedPath := fmt.Sprintf("%s/%s", os.TempDir(), fs.WorkingDir())
if fs.WorkingDirPath() != expectedPath { if fs.WorkingDirPath() != expectedPath {
@@ -26,7 +95,7 @@ func TestFileSystem_WorkingDirPath(t *testing.T) {
} }
func TestFileSystem_NewDirPath(t *testing.T) { func TestFileSystem_NewDirPath(t *testing.T) {
fs := NewFileSystem() fs := NewFileSystem(new(MkdirAllMock))
newDir := fs.NewDirPath() newDir := fs.NewDirPath()
expectedPrefix := fs.WorkingDirPath() expectedPrefix := fs.WorkingDirPath()
@@ -36,20 +105,42 @@ func TestFileSystem_NewDirPath(t *testing.T) {
} }
func TestFileSystem_MkdirAll(t *testing.T) { func TestFileSystem_MkdirAll(t *testing.T) {
fs := NewFileSystem() for _, tc := range []struct {
scenario string
mkdirAll MkdirAll
expectError bool
}{
{
scenario: "error",
mkdirAll: &MkdirAllMock{
MkdirAllMock: func(path string, perm os.FileMode) error {
return errors.New("foo")
},
},
expectError: true,
},
{
scenario: "success",
mkdirAll: &MkdirAllMock{
MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
},
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
fs := NewFileSystem(tc.mkdirAll)
newPath, err := fs.MkdirAll() _, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
_, err = os.Stat(newPath) if !tc.expectError && err != nil {
if os.IsNotExist(err) { t.Fatalf("expected no error but got: %v", err)
t.Errorf("expected directory '%s' to exist but it doesn't", newPath) }
}
err = os.RemoveAll(fs.WorkingDirPath()) if tc.expectError && err == nil {
if err != nil { t.Fatal("expected error but got none")
t.Fatalf("expected no error while cleaning up but got: %v", err) }
})
} }
} }

View File

@@ -5,13 +5,14 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"go.uber.org/zap" "go.uber.org/zap"
) )
// GarbageCollect scans the root path and deletes files or directories with // GarbageCollect scans the root path and deletes files or directories with
// names containing specific substrings. // names containing specific substrings and before a given experiation time.
func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string) error { func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error {
logger = logger.Named("gc") logger = logger.Named("gc")
// To make sure that the next Walk method stays on // 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 { 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) err := os.RemoveAll(path)
if err != nil { if err != nil {
return fmt.Errorf("garbage collect '%s': %w", path, err) return fmt.Errorf("garbage collect '%s': %w", path, err)

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"testing" "testing"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
@@ -30,7 +31,7 @@ func TestGarbageCollect(t *testing.T) {
err := os.MkdirAll(path, 0o755) err := os.MkdirAll(path, 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/a_foo_file", path), []byte{1}, 0o755) err = os.WriteFile(fmt.Sprintf("%s/a_foo_file", path), []byte{1}, 0o755)
@@ -64,7 +65,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 { if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)

View File

@@ -2,6 +2,7 @@ package gotenberg
import ( import (
"context" "context"
"os"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -33,9 +34,21 @@ func (mod *ValidatorMock) Validate() error {
return mod.ValidateMock() 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. // PdfEngineMock is a mock for the [PdfEngine] interface.
//
//nolint:dupl
type PdfEngineMock struct { type PdfEngineMock struct {
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, 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 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) 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 WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
@@ -45,6 +58,14 @@ func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inpu
return engine.MergeMock(ctx, logger, inputPaths, outputPath) 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 { func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath) return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
} }
@@ -137,6 +158,15 @@ func (provider *MetricsProviderMock) Metrics() ([]Metric, error) {
return provider.MetricsMock() 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. // PathRenameMock is a mock for the [PathRename] interface.
type PathRenameMock struct { type PathRenameMock struct {
RenameMock func(oldpath, newpath string) error RenameMock func(oldpath, newpath string) error
@@ -156,4 +186,6 @@ var (
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil) _ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
_ LoggerProvider = (*LoggerProviderMock)(nil) _ LoggerProvider = (*LoggerProviderMock)(nil)
_ MetricsProvider = (*MetricsProviderMock)(nil) _ MetricsProvider = (*MetricsProviderMock)(nil)
_ MkdirAll = (*MkdirAllMock)(nil)
_ PathRename = (*PathRenameMock)(nil)
) )

View File

@@ -2,6 +2,7 @@ package gotenberg
import ( import (
"context" "context"
"os"
"testing" "testing"
"go.uber.org/zap" "go.uber.org/zap"
@@ -47,11 +48,32 @@ func TestValidatorMock(t *testing.T) {
} }
} }
func TestDebuggableMock(t *testing.T) {
mock := &DebuggableMock{
DebugMock: func() map[string]interface{} {
return map[string]interface{}{
"foo": "bar",
}
},
}
d := mock.Debug()
if d == nil {
t.Errorf("expected debug data, but got nil")
}
}
func TestPDFEngineMock(t *testing.T) { func TestPDFEngineMock(t *testing.T) {
mock := &PdfEngineMock{ mock := &PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil return nil
}, },
SplitMock: func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error { ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
return nil return nil
}, },
@@ -68,6 +90,16 @@ func TestPDFEngineMock(t *testing.T) {
t.Errorf("expected no error from PdfEngineMock.Merge, but got: %v", err) t.Errorf("expected no error from PdfEngineMock.Merge, but got: %v", err)
} }
_, err = mock.Split(context.Background(), zap.NewNop(), SplitMode{}, "", "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.Split, but got: %v", err)
}
err = mock.Flatten(context.Background(), zap.NewNop(), "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
}
err = mock.Convert(context.Background(), zap.NewNop(), PdfFormats{}, "", "") err = mock.Convert(context.Background(), zap.NewNop(), PdfFormats{}, "", "")
if err != nil { if err != nil {
t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err) t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
@@ -205,6 +237,19 @@ func TestMetricsProviderMock(t *testing.T) {
} }
} }
func TestMkdirAllMock(t *testing.T) {
mock := &MkdirAllMock{
MkdirAllMock: func(dir string, perm os.FileMode) error {
return nil
},
}
err := mock.MkdirAll("/foo", 0o755)
if err != nil {
t.Errorf("expected no error from MkdirAllMock.MkdirAll, but got: %v", err)
}
}
func TestPathRenameMock(t *testing.T) { func TestPathRenameMock(t *testing.T) {
mock := &PathRenameMock{ mock := &PathRenameMock{
RenameMock: func(oldpath, newpath string) error { RenameMock: func(oldpath, newpath string) error {

View File

@@ -75,6 +75,12 @@ type SystemLogger interface {
SystemMessages() []string 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. // MustRegisterModule registers a module.
// //
// To register a module, create an init() method in the module main go file: // To register a module, create an init() method in the module main go file:

View File

@@ -12,6 +12,10 @@ var (
// PdfEngine interface is not supported by its current implementation. // PdfEngine interface is not supported by its current implementation.
ErrPdfEngineMethodNotSupported = errors.New("method not supported") ErrPdfEngineMethodNotSupported = errors.New("method not supported")
// ErrPdfSplitModeNotSupported is returned when the Split method of the
// PdfEngine interface does not sumport a requested PDF split mode.
ErrPdfSplitModeNotSupported = errors.New("split mode not supported")
// ErrPdfFormatNotSupported is returned when the Convert method of the // ErrPdfFormatNotSupported is returned when the Convert method of the
// PdfEngine interface does not support a requested PDF format conversion. // PdfEngine interface does not support a requested PDF format conversion.
ErrPdfFormatNotSupported = errors.New("PDF format not supported") ErrPdfFormatNotSupported = errors.New("PDF format not supported")
@@ -21,6 +25,30 @@ var (
ErrPdfEngineMetadataValueNotSupported = errors.New("metadata value 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 ( const (
// PdfA1a represents the PDF/A-1a format. // PdfA1a represents the PDF/A-1a format.
PdfA1a string = "PDF/A-1a" PdfA1a string = "PDF/A-1a"
@@ -60,11 +88,22 @@ type PdfFormats struct {
// PdfEngine provides an interface for operations on PDFs. Implementations // PdfEngine provides an interface for operations on PDFs. Implementations
// can utilize various tools like PDFtk, or implement functionality directly in // can utilize various tools like PDFtk, or implement functionality directly in
// Go. // Go.
//
//nolint:dupl
type PdfEngine interface { type PdfEngine interface {
// Merge combines multiple PDFs into a single PDF. The resulting page order // Merge combines multiple PDFs into a single PDF. The resulting page order
// is determined by the order of files provided in inputPaths. // is determined by the order of files provided in inputPaths.
Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error 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 // Convert transforms a given PDF to the specified formats defined in
// PdfFormats. If no format, it does nothing. // PdfFormats. If no format, it does nothing.
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error

View File

@@ -7,7 +7,8 @@ import (
) )
// AlphanumericSort implements sort.Interface and helps to sort strings // AlphanumericSort implements sort.Interface and helps to sort strings
// alphanumerically. // alphanumerically by either a numeric prefix or, if missing, a numeric
// suffix.
// //
// See: https://github.com/gotenberg/gotenberg/issues/805. // See: https://github.com/gotenberg/gotenberg/issues/805.
type AlphanumericSort []string type AlphanumericSort []string
@@ -21,20 +22,20 @@ func (s AlphanumericSort) Swap(i, j int) {
} }
func (s AlphanumericSort) Less(i, j int) bool { func (s AlphanumericSort) Less(i, j int) bool {
numI, restI := extractPrefix(s[i]) numI, restI := extractNumber(s[i])
numJ, restJ := extractPrefix(s[j]) numJ, restJ := extractNumber(s[j])
// Compares numerical prefixes if they exist. // If both strings contain a number, compare them numerically.
if numI != -1 && numJ != -1 { if numI != -1 && numJ != -1 {
if numI != numJ { if numI != numJ {
return numI < numJ return numI < numJ
} }
// If numbers are equal, falls back to string comparison of the rest. // If the numbers are equal, compare the "rest" strings.
return restI < restJ return restI < restJ
} }
// If one has a numerical prefix and the other doesn't, the one with the // If one contains a number and the other doesn't, the one with the number
// number comes first. // comes first.
if numI != -1 { if numI != -1 {
return true return true
} }
@@ -42,28 +43,52 @@ func (s AlphanumericSort) Less(i, j int) bool {
return false return false
} }
// If neither has a numerical prefix, compare as strings // Neither has a number; fall back to lexicographical order.
return s[i] < s[j] return s[i] < s[j]
} }
// extractPrefix attempts to extract a numerical prefix and the rest of the filename // extractNumber attempts to extract a numeric portion from the filename.
func extractPrefix(filename string) (int, string) { // It first checks for a numeric prefix (digits at the beginning).
matches := numPrefixRegexp.FindStringSubmatch(filename) // If none is found, it next attempts to match a number immediately before the
if len(matches) > 2 { // extension (for filenames such as "sample1_1.pdf").
prefix, err := strconv.Atoi(matches[1]) // If that fails, it then attempts a trailing numeric pattern.
if err == nil { // If no number is found, it returns -1 and the original string.
return prefix, matches[2] func extractNumber(str string) (int, string) {
// 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]
} }
} }
// Returns -1 if no numerical prefix is found, indicating to just compare // Check for a number immediately before an extension.
// as strings. if matches := extensionSuffixRegexp.FindStringSubmatch(str); len(matches) > 3 {
return -1, filename 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
} }
var numPrefixRegexp = regexp.MustCompile(`^(\d+)(.*)$`) // 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. // Interface guard.
var ( var _ sort.Interface = (*AlphanumericSort)(nil)
_ sort.Interface = (*AlphanumericSort)(nil)
)

View File

@@ -17,6 +17,16 @@ func TestAlphanumericSort(t *testing.T) {
values: []string{"10qux.pdf", "2_baz.txt", "2_aza.txt", "1bar.pdf", "Afoo.txt", "Bbar.docx", "25zeta.txt", "3.pdf", "4_foo.pdf"}, 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"}, 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)", scenario: "hrtime (PHP library)",
values: []string{"245654773395259", "245654773395039", "245654773395149", "245654773394919", "245654773394369"}, values: []string{"245654773395259", "245654773395039", "245654773395149", "245654773394919", "245654773394369"},

View File

@@ -42,6 +42,7 @@ type Api struct {
basicAuthPassword string basicAuthPassword string
downloadFromCfg downloadFromConfig downloadFromCfg downloadFromConfig
disableHealthCheckLogging bool disableHealthCheckLogging bool
enableDebugRoute bool
routes []Route routes []Route
externalMiddlewares []Middleware externalMiddlewares []Middleware
@@ -187,6 +188,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature") fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature") fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging") 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 return fs
}(), }(),
New: func() gotenberg.Module { return new(Api) }, New: func() gotenberg.Module { return new(Api) },
@@ -212,6 +214,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
disable: flags.MustBool("api-disable-download-from"), disable: flags.MustBool("api-disable-download-from"),
} }
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging") a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
a.enableDebugRoute = flags.MustBool("api-enable-debug-route")
// Port from env? // Port from env?
portEnvVar := flags.MustString("api-port-from-env") portEnvVar := flags.MustString("api-port-from-env")
@@ -318,7 +321,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
a.logger = logger a.logger = logger
// File system. // File system.
a.fs = gotenberg.NewFileSystem() a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
return nil return nil
} }
@@ -365,9 +368,10 @@ func (a *Api) Validate() error {
return err return err
} }
routesMap := make(map[string]string, len(a.routes)+2) routesMap := make(map[string]string, len(a.routes)+3)
routesMap["/health"] = "/health" routesMap["/health"] = "/health"
routesMap["/version"] = "/version" routesMap["/version"] = "/version"
routesMap["/debug"] = "/debug"
for _, route := range a.routes { for _, route := range a.routes {
if route.Path == "" { if route.Path == "" {
@@ -456,14 +460,22 @@ func (a *Api) Start() error {
hardTimeout := a.timeout + (time.Duration(5) * time.Second) 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. // Add the modules' routes and their specific middlewares.
for _, route := range a.routes { for _, route := range a.routes {
var middlewares []echo.MiddlewareFunc var middlewares []echo.MiddlewareFunc
middlewares = append(middlewares, securityMiddleware)
// Basic auth?
if a.basicAuthUsername != "" {
middlewares = append(middlewares, basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword))
}
if route.IsMultipart { if route.IsMultipart {
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg)) middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg))
@@ -483,6 +495,24 @@ func (a *Api) Start() error {
) )
} }
// 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... // Let's not forget the health check routes...
checks := append(a.healthChecks, health.WithTimeout(a.timeout)) checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...) checker := health.NewChecker(checks...)
@@ -503,14 +533,26 @@ func (a *Api) Start() error {
hardTimeoutMiddleware(hardTimeout), hardTimeoutMiddleware(hardTimeout),
) )
// ...and the version route. // ...the version route.
a.srv.GET( a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "version"), fmt.Sprintf("%s%s", a.rootPath, "version"),
func(c echo.Context) error { func(c echo.Context) error {
return c.String(http.StatusOK, gotenberg.Version) 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. // Wait for all modules to be ready.
ctx, cancel := context.WithTimeout(context.Background(), a.startTimeout) ctx, cancel := context.WithTimeout(context.Background(), a.startTimeout)
defer cancel() defer cancel()

View File

@@ -786,6 +786,7 @@ func TestApi_Start(t *testing.T) {
mod.basicAuthUsername = "foo" mod.basicAuthUsername = "foo"
mod.basicAuthPassword = "bar" mod.basicAuthPassword = "bar"
mod.disableHealthCheckLogging = true mod.disableHealthCheckLogging = true
mod.enableDebugRoute = true
mod.routes = []Route{ mod.routes = []Route{
{ {
Method: http.MethodPost, Method: http.MethodPost,
@@ -850,7 +851,7 @@ func TestApi_Start(t *testing.T) {
}, },
} }
mod.readyFn = tc.readyFn mod.readyFn = tc.readyFn
mod.fs = gotenberg.NewFileSystem() mod.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
mod.logger = zap.NewNop() mod.logger = zap.NewNop()
err := mod.Start() err := mod.Start()
@@ -866,15 +867,33 @@ func TestApi_Start(t *testing.T) {
return return
} }
// health requests. // root request.
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
rootRequest := httptest.NewRequest(http.MethodGet, "/", nil)
rootRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
mod.srv.ServeHTTP(recorder, rootRequest)
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
// favicon request.
recorder = httptest.NewRecorder()
faviconRequest := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
faviconRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
mod.srv.ServeHTTP(recorder, faviconRequest)
if recorder.Code != http.StatusNoContent {
t.Errorf("expected %d status code but got %d", http.StatusNoContent, recorder.Code)
}
// health requests.
recorder = httptest.NewRecorder()
healthGetRequest := httptest.NewRequest(http.MethodGet, "/health", nil) healthGetRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
mod.srv.ServeHTTP(recorder, healthGetRequest) mod.srv.ServeHTTP(recorder, healthGetRequest)
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
} }
recorder = httptest.NewRecorder()
healthHeadRequest := httptest.NewRequest(http.MethodHead, "/health", nil) healthHeadRequest := httptest.NewRequest(http.MethodHead, "/health", nil)
mod.srv.ServeHTTP(recorder, healthHeadRequest) mod.srv.ServeHTTP(recorder, healthHeadRequest)
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
@@ -882,12 +901,23 @@ func TestApi_Start(t *testing.T) {
} }
// version request. // version request.
recorder = httptest.NewRecorder()
versionRequest := httptest.NewRequest(http.MethodGet, "/version", nil) versionRequest := httptest.NewRequest(http.MethodGet, "/version", nil)
versionRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
mod.srv.ServeHTTP(recorder, versionRequest) mod.srv.ServeHTTP(recorder, versionRequest)
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
} }
// debug request.
recorder = httptest.NewRecorder()
debugRequest := httptest.NewRequest(http.MethodGet, "/debug", nil)
debugRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
mod.srv.ServeHTTP(recorder, debugRequest)
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
// "multipart/form-data" request. // "multipart/form-data" request.
multipartRequest := func(url string) *http.Request { multipartRequest := func(url string) *http.Request {
body := &bytes.Buffer{} body := &bytes.Buffer{}

View File

@@ -1,7 +1,6 @@
package api package api
import ( import (
"compress/flate"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -19,7 +18,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mholt/archiver/v3" "github.com/mholt/archives"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
@@ -47,6 +46,7 @@ type Context struct {
logger *zap.Logger logger *zap.Logger
echoCtx echo.Context echoCtx echo.Context
mkdirAll gotenberg.MkdirAll
pathRename gotenberg.PathRename pathRename gotenberg.PathRename
context.Context context.Context
} }
@@ -81,12 +81,6 @@ type downloadFrom struct {
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"` ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
} }
type osPathRename struct{}
func (o *osPathRename) Rename(oldpath, newpath string) error {
return os.Rename(oldpath, newpath)
}
// newContext returns a [Context] by parsing a "multipart/form-data" request. // 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, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*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) processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
@@ -112,7 +106,8 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
cancelled: false, cancelled: false,
logger: logger, logger: logger,
echoCtx: echoCtx, echoCtx: echoCtx,
pathRename: new(osPathRename), mkdirAll: new(gotenberg.OsMkdirAll),
pathRename: new(gotenberg.OsPathRename),
Context: processCtx, Context: processCtx,
} }
@@ -414,9 +409,21 @@ func (ctx *Context) GeneratePath(extension string) string {
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New().String(), extension) return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New().String(), extension)
} }
// 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 // Rename is just a wrapper around [os.Rename], as we need to mock this
// behavior in our tests. // behavior in our tests.
func (ctx *Context) Rename(oldpath, newpath string) error { 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) err := ctx.pathRename.Rename(oldpath, newpath)
if err != nil { if err != nil {
return fmt.Errorf("rename path: %w", err) return fmt.Errorf("rename path: %w", err)
@@ -460,22 +467,33 @@ func (ctx *Context) BuildOutputFile() (string, error) {
if len(ctx.outputPaths) == 1 { if len(ctx.outputPaths) == 1 {
ctx.logger.Debug(fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0])) ctx.logger.Debug(fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0]))
return ctx.outputPaths[0], nil return ctx.outputPaths[0], nil
} }
z := archiver.Zip{ filesInfo, err := archives.FilesFromDisk(ctx.Context, nil, func() map[string]string {
CompressionLevel: flate.DefaultCompression, f := make(map[string]string)
MkdirAll: true, for _, outputPath := range ctx.outputPaths {
SelectiveCompression: true, f[outputPath] = ""
ContinueOnError: false, }
OverwriteExisting: false, return f
ImplicitTopLevelFolder: false, }())
if err != nil {
return "", fmt.Errorf("create files info: %w", err)
} }
archivePath := ctx.GeneratePath(".zip") 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 { if err != nil {
return "", fmt.Errorf("archive output files: %w", err) return "", fmt.Errorf("archive output files: %w", err)
} }
@@ -496,8 +514,3 @@ func (ctx *Context) OutputFilename(outputPath string) string {
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath)) return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
} }
// Interface guard.
var (
_ gotenberg.PathRename = (*osPathRename)(nil)
)

View File

@@ -4,78 +4,22 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
func TestOsPathRename_Rename(t *testing.T) {
dirPath, err := gotenberg.NewFileSystem().MkdirAll()
if err != nil {
t.Fatalf("create working directory: %v", err)
}
path := "/tests/test/testdata/api/sample1.txt"
copyPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
in, err := os.Open(path)
if err != nil {
t.Fatalf("open file: %v", err)
}
defer func() {
err := in.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}()
out, err := os.Create(copyPath)
if err != nil {
t.Fatalf("create new file: %v", err)
}
defer func() {
err := out.Close()
if err != nil {
t.Fatalf("close new file: %v", err)
}
}()
_, err = io.Copy(out, in)
if err != nil {
t.Fatalf("copy file to new file: %v", err)
}
rename := new(osPathRename)
newPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
err = rename.Rename(copyPath, newPath)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
err = os.RemoveAll(dirPath)
if err != nil {
t.Fatalf("remove working directory: %v", err)
}
}
func TestNewContext(t *testing.T) { func TestNewContext(t *testing.T) {
defaultAllowList, err := regexp2.Compile("", 0) defaultAllowList, err := regexp2.Compile("", 0)
if err != nil { if err != nil {
@@ -548,7 +492,7 @@ func TestNewContext(t *testing.T) {
} }
handler := func(c echo.Context) error { handler := func(c echo.Context) error {
ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second, tc.bodyLimit, tc.downloadFromCfg, "Gotenberg-Trace", "123") ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), time.Duration(10)*time.Second, tc.bodyLimit, tc.downloadFromCfg, "Gotenberg-Trace", "123")
defer cancel() defer cancel()
// Context already cancelled. // Context already cancelled.
defer cancel() defer cancel()
@@ -647,6 +591,42 @@ func TestContext_FormData(t *testing.T) {
} }
} }
func TestContext_CreateSubDirectory(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *Context
expectError bool
}{
{
scenario: "failure",
ctx: &Context{mkdirAll: &gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return errors.New("cannot rename")
}}},
expectError: true,
},
{
scenario: "success",
ctx: &Context{mkdirAll: &gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.logger = zap.NewNop()
_, err := tc.ctx.CreateSubDirectory("foo")
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_GeneratePath(t *testing.T) { func TestContext_GeneratePath(t *testing.T) {
ctx := &Context{ ctx := &Context{
dirPath: "/foo", dirPath: "/foo",
@@ -680,6 +660,7 @@ func TestContext_Rename(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.logger = zap.NewNop()
err := tc.ctx.Rename("", "") err := tc.ctx.Rename("", "")
if tc.expectError && err == nil { if tc.expectError && err == nil {
@@ -788,7 +769,7 @@ func TestContext_BuildOutputFile(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
dirPath, err := fs.MkdirAll() dirPath, err := fs.MkdirAll()
if err != nil { if err != nil {
t.Fatalf("expected no erro but got: %v", err) t.Fatalf("expected no erro but got: %v", err)
@@ -802,6 +783,7 @@ func TestContext_BuildOutputFile(t *testing.T) {
}() }()
tc.ctx.dirPath = dirPath tc.ctx.dirPath = dirPath
tc.ctx.Context = context.Background()
tc.ctx.logger = zap.NewNop() tc.ctx.logger = zap.NewNop()
_, err = tc.ctx.BuildOutputFile() _, err = tc.ctx.BuildOutputFile()

View File

@@ -48,6 +48,10 @@ func ParseError(err error) (int, string) {
return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests) 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) { if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues" return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues"
} }
@@ -157,9 +161,12 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
trace := c.Get("trace").(string) trace := c.Get("trace").(string)
rootPath := c.Get("rootPath").(string) rootPath := c.Get("rootPath").(string)
// Create the request logger and add it to our locals. // Create the application logger and add it to our locals.
reqLogger := logger.With(zap.String("trace", trace)) appLogger := logger.
c.Set("logger", reqLogger.Named(func() string { With(zap.String("log_type", "application")).
With(zap.String("trace", trace))
c.Set("logger", appLogger.Named(func() string {
return strings.ReplaceAll( return strings.ReplaceAll(
strings.ReplaceAll(c.Request().URL.Path, rootPath, ""), strings.ReplaceAll(c.Request().URL.Path, rootPath, ""),
"/", "/",
@@ -173,6 +180,11 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
c.Error(err) c.Error(err)
} }
// Create the access logger.
accessLogger := logger.
With(zap.String("log_type", "access")).
With(zap.String("trace", trace))
for _, path := range disableLoggingForPaths { for _, path := range disableLoggingForPaths {
URI := fmt.Sprintf("%s%s", rootPath, path) URI := fmt.Sprintf("%s%s", rootPath, path)
@@ -208,9 +220,9 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
fields[11] = zap.Int64("bytes_out", c.Response().Size) fields[11] = zap.Int64("bytes_out", c.Response().Size)
if err != nil { if err != nil {
reqLogger.Error(err.Error(), fields...) accessLogger.Error(err.Error(), fields...)
} else { } else {
reqLogger.Info("request handled", fields...) accessLogger.Info("request handled", fields...)
} }
return nil return nil

View File

@@ -38,6 +38,11 @@ func TestParseError(t *testing.T) {
expectStatus: http.StatusTooManyRequests, expectStatus: http.StatusTooManyRequests,
expectMessage: http.StatusText(http.StatusTooManyRequests), expectMessage: http.StatusText(http.StatusTooManyRequests),
}, },
{
err: gotenberg.ErrPdfSplitModeNotSupported,
expectStatus: http.StatusBadRequest,
expectMessage: "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues",
},
{ {
err: gotenberg.ErrPdfFormatNotSupported, err: gotenberg.ErrPdfFormatNotSupported,
expectStatus: http.StatusBadRequest, expectStatus: http.StatusBadRequest,
@@ -462,7 +467,7 @@ func TestContextMiddleware(t *testing.T) {
c.Set("trace", "foo") c.Set("trace", "foo")
c.Set("startTime", time.Now()) c.Set("startTime", time.Now())
err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second, 0, downloadFromConfig{})(tc.next)(c) err := contextMiddleware(gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), time.Duration(10)*time.Second, 0, downloadFromConfig{})(tc.next)(c)
if tc.expectErr && err == nil { if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Errorf("test %d: expected error but got: %v", i, err)

View File

@@ -86,6 +86,14 @@ func (ctx *ContextMock) SetEchoContext(c echo.Context) {
ctx.Context.echoCtx = c ctx.Context.echoCtx = c
} }
// SetMkdirAll sets the [gotenberg.MkdirAll].
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetMkdirAll(mkdirAll)
func (ctx *ContextMock) SetMkdirAll(mkdirAll gotenberg.MkdirAll) {
ctx.Context.mkdirAll = mkdirAll
}
// SetPathRename sets the [gotenberg.PathRename]. // SetPathRename sets the [gotenberg.PathRename].
// //
// ctx := &api.ContextMock{Context: &api.Context{}} // ctx := &api.ContextMock{Context: &api.Context{}}

View File

@@ -7,6 +7,8 @@ import (
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
func TestContextMock_SetDirPath(t *testing.T) { func TestContextMock_SetDirPath(t *testing.T) {
@@ -117,10 +119,23 @@ func TestContextMock_SetEchoContext(t *testing.T) {
} }
} }
func TestContextMock_SetMkdirAll(t *testing.T) {
mock := ContextMock{&Context{}}
expect := new(gotenberg.OsMkdirAll)
mock.SetMkdirAll(expect)
actual := mock.mkdirAll
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestContextMock_SetPathRename(t *testing.T) { func TestContextMock_SetPathRename(t *testing.T) {
mock := ContextMock{&Context{}} mock := ContextMock{&Context{}}
expect := new(osPathRename) expect := new(gotenberg.OsPathRename)
mock.SetPathRename(expect) mock.SetPathRename(expect)
actual := mock.pathRename actual := mock.pathRename

View File

@@ -15,6 +15,7 @@ import (
"github.com/chromedp/cdproto/runtime" "github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"github.com/shirou/gopsutil/v4/process"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -62,7 +63,7 @@ func newChromiumBrowser(arguments browserArguments) browser {
b := &chromiumBrowser{ b := &chromiumBrowser{
initialCtx: context.Background(), initialCtx: context.Background(),
arguments: arguments, arguments: arguments,
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
} }
b.isStarted.Store(false) b.isStarted.Store(false)
@@ -162,21 +163,61 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
// Always remove the user profile directory created by Chromium. // Always remove the user profile directory created by Chromium.
copyUserProfileDirPath := b.userProfileDirPath 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() { go func() {
// FIXME: Chromium seems to recreate the user profile directory // FIXME: Chromium seems to recreate the user profile directory
// right after its deletion if we do not wait a certain amount // right after its deletion if we do not wait a certain amount
// of time before deleting it. // of time before deleting it.
<-time.After(10 * time.Second) <-time.After(10 * time.Second)
err := os.RemoveAll(userProfileDirPath) err = os.RemoveAll(userProfileDirPath)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err)) 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() b.ctxMu.Lock()
defer b.ctxMu.Unlock() defer b.ctxMu.Unlock()

View File

@@ -263,7 +263,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
b.isStarted.Store(false) b.isStarted.Store(false)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: false, noDeadline: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -275,7 +275,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
b.isStarted.Store(true) b.isStarted.Store(true)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: true, noDeadline: true,
start: false, start: false,
expectError: true, expectError: true,
@@ -291,7 +291,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
b.isStarted.Store(true) b.isStarted.Store(true)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: false, noDeadline: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -308,7 +308,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
b.isStarted.Store(true) b.isStarted.Store(true)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: false, noDeadline: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -325,11 +325,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<iframe src='file:///etc/passwd'></iframe>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<iframe src='file:///etc/passwd'></iframe>"), 0o755)
@@ -357,11 +357,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<iframe src='file:///etc/passwd'></iframe>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<iframe src='file:///etc/passwd'></iframe>"), 0o755)
@@ -389,11 +389,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Skip networkIdle event</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Skip networkIdle event</h1>"), 0o755)
@@ -424,11 +424,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidHttpStatusCode</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidHttpStatusCode</h1>"), 0o755)
@@ -457,11 +457,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/style.css", fs.WorkingDirPath()), []byte("body{font-family: Arial, Helvetica, sans-serif;}"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/style.css", fs.WorkingDirPath()), []byte("body{font-family: Arial, Helvetica, sans-serif;}"), 0o755)
@@ -495,11 +495,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">throw new Error(\"Exception\")</script>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">throw new Error(\"Exception\")</script>"), 0o755)
@@ -528,11 +528,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
return fs return fs
@@ -554,11 +554,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"http://localhost:100/style.css\"></head><body><h1>ErrResourceLoadingFailed</h1></body></html>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"http://localhost:100/style.css\"></head><body><h1>ErrResourceLoadingFailed</h1></body></html>"), 0o755)
@@ -588,11 +588,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Clear cache</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Clear cache</h1>"), 0o755)
@@ -621,11 +621,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Clear cookies</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Clear cookies</h1>"), 0o755)
@@ -654,11 +654,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">throw new Error(\"Exception\")</script>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">throw new Error(\"Exception\")</script>"), 0o755)
@@ -688,11 +688,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Set cookies</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Set cookies</h1>"), 0o755)
@@ -723,11 +723,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>User-Agent override</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>User-Agent override</h1>"), 0o755)
@@ -758,11 +758,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Extra HTTP headers</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Extra HTTP headers</h1>"), 0o755)
@@ -812,11 +812,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrOmitBackgroundWithoutPrintBackground</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrOmitBackgroundWithoutPrintBackground</h1>"), 0o755)
@@ -845,11 +845,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Hide default white background</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Hide default white background</h1>"), 0o755)
@@ -881,11 +881,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidEmulatedMediaType</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidEmulatedMediaType</h1>"), 0o755)
@@ -914,11 +914,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<style>@media print { #screen { display: none } }</style><p id=\"screen\">Screen media type</p>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<style>@media print { #screen { display: none } }</style><p id=\"screen\">Screen media type</p>"), 0o755)
@@ -949,11 +949,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">await new Promise(r => setTimeout(r, 10000));</script>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">await new Promise(r => setTimeout(r, 10000));</script>"), 0o755)
@@ -984,11 +984,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">await new Promise(r => setTimeout(r, 10000));</script>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">await new Promise(r => setTimeout(r, 10000));</script>"), 0o755)
@@ -1019,11 +1019,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
html := ` html := `
@@ -1065,11 +1065,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidEvaluationExpression</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidEvaluationExpression</h1>"), 0o755)
@@ -1100,11 +1100,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
html := ` html := `
@@ -1146,11 +1146,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Custom header and footer</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Custom header and footer</h1>"), 0o755)
@@ -1181,11 +1181,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Custom header and footer</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Custom header and footer</h1>"), 0o755)
@@ -1217,11 +1217,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidPrinterSettings</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidPrinterSettings</h1>"), 0o755)
@@ -1255,11 +1255,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrPageRangesSyntaxError</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrPageRangesSyntaxError</h1>"), 0o755)
@@ -1288,11 +1288,11 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Default options</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Default options</h1>"), 0o755)
@@ -1421,7 +1421,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
b.isStarted.Store(false) b.isStarted.Store(false)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: false, noDeadline: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -1437,7 +1437,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
b.isStarted.Store(true) b.isStarted.Store(true)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: true, noDeadline: true,
start: false, start: false,
expectError: true, expectError: true,
@@ -1453,7 +1453,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
b.isStarted.Store(true) b.isStarted.Store(true)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: false, noDeadline: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -1470,7 +1470,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
b.isStarted.Store(true) b.isStarted.Store(true)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: false, noDeadline: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -1487,11 +1487,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<iframe src='file:///etc/passwd'></iframe>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<iframe src='file:///etc/passwd'></iframe>"), 0o755)
@@ -1519,11 +1519,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<iframe src='file:///etc/passwd'></iframe>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<iframe src='file:///etc/passwd'></iframe>"), 0o755)
@@ -1551,11 +1551,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Skip networkIdle event</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Skip networkIdle event</h1>"), 0o755)
@@ -1586,11 +1586,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidHttpStatusCode</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidHttpStatusCode</h1>"), 0o755)
@@ -1619,11 +1619,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/style.css", fs.WorkingDirPath()), []byte("body{font-family: Arial, Helvetica, sans-serif;}"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/style.css", fs.WorkingDirPath()), []byte("body{font-family: Arial, Helvetica, sans-serif;}"), 0o755)
@@ -1657,11 +1657,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">throw new Error(\"Exception\")</script>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">throw new Error(\"Exception\")</script>"), 0o755)
@@ -1690,11 +1690,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
return fs return fs
@@ -1716,11 +1716,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"http://localhost:100/style.css\"></head><body><h1>ErrResourceLoadingFailed</h1></body></html>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"http://localhost:100/style.css\"></head><body><h1>ErrResourceLoadingFailed</h1></body></html>"), 0o755)
@@ -1750,11 +1750,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Clear cache</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Clear cache</h1>"), 0o755)
@@ -1783,11 +1783,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Clear cookies</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Clear cookies</h1>"), 0o755)
@@ -1816,11 +1816,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">throw new Error(\"Exception\")</script>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">throw new Error(\"Exception\")</script>"), 0o755)
@@ -1850,11 +1850,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Set cookies</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Set cookies</h1>"), 0o755)
@@ -1885,11 +1885,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>User-Agent override</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>User-Agent override</h1>"), 0o755)
@@ -1920,11 +1920,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Extra HTTP headers</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Extra HTTP headers</h1>"), 0o755)
@@ -1974,11 +1974,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrOmitBackgroundWithoutPrintBackground</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrOmitBackgroundWithoutPrintBackground</h1>"), 0o755)
@@ -2009,11 +2009,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidEmulatedMediaType</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidEmulatedMediaType</h1>"), 0o755)
@@ -2042,11 +2042,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<style>@media print { #screen { display: none } }</style><p id=\"screen\">Screen media type</p>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<style>@media print { #screen { display: none } }</style><p id=\"screen\">Screen media type</p>"), 0o755)
@@ -2077,11 +2077,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">await new Promise(r => setTimeout(r, 10000));</script>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">await new Promise(r => setTimeout(r, 10000));</script>"), 0o755)
@@ -2112,11 +2112,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">await new Promise(r => setTimeout(r, 10000));</script>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<script type=\"application/javascript\">await new Promise(r => setTimeout(r, 10000));</script>"), 0o755)
@@ -2147,11 +2147,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
html := ` html := `
@@ -2193,11 +2193,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidEvaluationExpression</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>ErrInvalidEvaluationExpression</h1>"), 0o755)
@@ -2228,11 +2228,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
html := ` html := `
@@ -2274,11 +2274,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Default options</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Default options</h1>"), 0o755)
@@ -2317,11 +2317,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Default options</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Default options</h1>"), 0o755)
@@ -2365,11 +2365,11 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Default options</h1>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Default options</h1>"), 0o755)

View File

@@ -5,6 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"strings"
"syscall"
"time" "time"
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
@@ -352,7 +355,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
ID: "chromium", ID: "chromium",
FlagSet: func() *flag.FlagSet { FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("chromium", flag.ExitOnError) 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.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.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.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
@@ -484,6 +487,23 @@ func (mod *Chromium) Stop(ctx context.Context) error {
return fmt.Errorf("stop Chromium: %w", err) 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. // Metrics returns the metrics.
func (mod *Chromium) Metrics() ([]gotenberg.Metric, error) { func (mod *Chromium) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{ return []gotenberg.Metric{
@@ -593,6 +613,7 @@ var (
_ gotenberg.Provisioner = (*Chromium)(nil) _ gotenberg.Provisioner = (*Chromium)(nil)
_ gotenberg.Validator = (*Chromium)(nil) _ gotenberg.Validator = (*Chromium)(nil)
_ gotenberg.App = (*Chromium)(nil) _ gotenberg.App = (*Chromium)(nil)
_ gotenberg.Debuggable = (*Chromium)(nil)
_ gotenberg.MetricsProvider = (*Chromium)(nil) _ gotenberg.MetricsProvider = (*Chromium)(nil)
_ api.HealthChecker = (*Chromium)(nil) _ api.HealthChecker = (*Chromium)(nil)
_ api.Router = (*Chromium)(nil) _ api.Router = (*Chromium)(nil)

View File

@@ -336,6 +336,54 @@ func TestChromium_Stop(t *testing.T) {
} }
} }
func TestChromium_Debug(t *testing.T) {
for _, tc := range []struct {
scenario string
mod *Chromium
expect map[string]interface{}
doNotExpect map[string]interface{}
}{
{
scenario: "cannot determine version",
mod: &Chromium{
args: browserArguments{
binPath: "foo",
},
},
expect: map[string]interface{}{
"version": `exec: "foo": executable file not found in $PATH`,
},
},
{
scenario: "success",
mod: &Chromium{
args: browserArguments{
binPath: "echo",
},
},
doNotExpect: map[string]interface{}{
"version": `exec: "echo": executable file not found in $PATH`,
},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
d := tc.mod.Debug()
if tc.expect != nil {
if !reflect.DeepEqual(d, tc.expect) {
t.Errorf("expected '%v' but got '%v'", tc.expect, d)
}
}
if tc.doNotExpect != nil {
if reflect.DeepEqual(d, tc.doNotExpect) {
t.Errorf("did not expect '%v'", d)
}
}
})
}
}
func TestChromium_Metrics(t *testing.T) { func TestChromium_Metrics(t *testing.T) {
mod := new(Chromium) mod := new(Chromium)
mod.supervisor = &gotenberg.ProcessSupervisorMock{ mod.supervisor = &gotenberg.ProcessSupervisorMock{

View File

@@ -21,6 +21,7 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "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 // FormDataChromiumOptions creates [Options] from the form data. Fallback to
@@ -316,39 +317,6 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
return form, screenshotOptions 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,
}
}
// FormDataPdfMetadata creates metadata object from the form data.
func FormDataPdfMetadata(form *api.FormData) map[string]interface{} {
var metadata map[string]interface{}
form.Custom("metadata", func(value string) error {
if len(value) > 0 {
err := json.Unmarshal([]byte(value), &metadata)
if err != nil {
return fmt.Errorf("unmarshal metadata: %w", err)
}
}
return nil
})
return metadata
}
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF. // convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return api.Route{ return api.Route{
@@ -358,8 +326,9 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx) form, options := FormDataChromiumPdfOptions(ctx)
pdfFormats := FormDataChromiumPdfFormats(form) mode := pdfengines.FormDataPdfSplitMode(form, false)
metadata := FormDataPdfMetadata(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
var url string var url string
err := form. err := form.
@@ -369,7 +338,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
if err != nil { if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err) return fmt.Errorf("convert URL to PDF: %w", err)
} }
@@ -418,8 +387,9 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx) form, options := FormDataChromiumPdfOptions(ctx)
pdfFormats := FormDataChromiumPdfFormats(form) mode := pdfengines.FormDataPdfSplitMode(form, false)
metadata := FormDataPdfMetadata(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
var inputPath string var inputPath string
err := form. err := form.
@@ -430,7 +400,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
} }
url := fmt.Sprintf("file://%s", inputPath) url := fmt.Sprintf("file://%s", inputPath)
err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
if err != nil { if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err) return fmt.Errorf("convert HTML to PDF: %w", err)
} }
@@ -480,8 +450,9 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx) form, options := FormDataChromiumPdfOptions(ctx)
pdfFormats := FormDataChromiumPdfFormats(form) mode := pdfengines.FormDataPdfSplitMode(form, false)
metadata := FormDataPdfMetadata(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
var ( var (
inputPath string inputPath string
@@ -501,7 +472,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("transform markdown file(s) to HTML: %w", err) return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
} }
err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
if err != nil { if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err) return fmt.Errorf("convert markdown to PDF: %w", err)
} }
@@ -625,7 +596,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
return fmt.Sprintf("file://%s", inputPath), nil return fmt.Sprintf("file://%s", inputPath), nil
} }
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}) 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") outputPath := ctx.GeneratePath(".pdf")
err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options) err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options)
@@ -664,34 +635,39 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("convert to PDF: %w", err) return fmt.Errorf("convert to PDF: %w", err)
} }
// So far so good, the URL has been converted to PDF. outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath})
// 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 {
return fmt.Errorf("convert PDF: %w", err)
}
// Important: the output path is now the converted file.
outputPath = convertOutputPath
}
// Writes and potentially overrides metadata entries, if any.
if len(metadata) > 0 {
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
}
err = ctx.AddOutputPaths(outputPath)
if err != nil { if err != nil {
return fmt.Errorf("add output path: %w", err) return fmt.Errorf("split PDF: %w", err)
}
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
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 return nil

View File

@@ -603,125 +603,6 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
} }
} }
func TestFormDataChromiumPdfFormats(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *api.ContextMock
expectedPdfFormats gotenberg.PdfFormats
expectValidationError bool
}{
{
scenario: "no custom form fields",
ctx: &api.ContextMock{Context: new(api.Context)},
expectedPdfFormats: gotenberg.PdfFormats{},
expectValidationError: false,
},
{
scenario: "pdfa and pdfua form fields",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"pdfa": {
"foo",
},
"pdfua": {
"true",
},
})
return ctx
}(),
expectedPdfFormats: gotenberg.PdfFormats{PdfA: "foo", PdfUa: true},
expectValidationError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop())
form := tc.ctx.Context.FormData()
actual := FormDataChromiumPdfFormats(form)
if !reflect.DeepEqual(actual, tc.expectedPdfFormats) {
t.Fatalf("expected %+v but got: %+v", tc.expectedPdfFormats, actual)
}
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
}
})
}
}
func TestFormDataPdfMetadata(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *api.ContextMock
expectedMetadata map[string]interface{}
expectValidationError bool
}{
{
scenario: "no metadata form field",
ctx: &api.ContextMock{Context: new(api.Context)},
expectedMetadata: nil,
expectValidationError: false,
},
{
scenario: "invalid metadata form field",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"metadata": {
"foo",
},
})
return ctx
}(),
expectedMetadata: nil,
expectValidationError: true,
},
{
scenario: "valid metadata form field",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"metadata": {
"{\"foo\":\"bar\"}",
},
})
return ctx
}(),
expectedMetadata: map[string]interface{}{
"foo": "bar",
},
expectValidationError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop())
form := tc.ctx.Context.FormData()
actual := FormDataPdfMetadata(form)
if !reflect.DeepEqual(actual, tc.expectedMetadata) {
t.Fatalf("expected %+v but got: %+v", tc.expectedMetadata, actual)
}
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
}
})
}
}
func TestConvertUrlRoute(t *testing.T) { func TestConvertUrlRoute(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
@@ -1171,7 +1052,7 @@ func TestConvertMarkdownRoute(t *testing.T) {
err := os.MkdirAll(dirPath, 0o755) err := os.MkdirAll(dirPath, 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755)
@@ -1199,7 +1080,7 @@ func TestConvertMarkdownRoute(t *testing.T) {
err := os.MkdirAll(dirPath, 0o755) err := os.MkdirAll(dirPath, 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755)
@@ -1226,7 +1107,7 @@ func TestConvertMarkdownRoute(t *testing.T) {
err := os.MkdirAll(dirPath, 0o755) err := os.MkdirAll(dirPath, 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755)
@@ -1261,7 +1142,7 @@ func TestConvertMarkdownRoute(t *testing.T) {
err := os.MkdirAll(dirPath, 0o755) err := os.MkdirAll(dirPath, 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755)
@@ -1378,7 +1259,7 @@ func TestScreenshotMarkdownRoute(t *testing.T) {
err := os.MkdirAll(dirPath, 0o755) err := os.MkdirAll(dirPath, 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755)
@@ -1406,7 +1287,7 @@ func TestScreenshotMarkdownRoute(t *testing.T) {
err := os.MkdirAll(dirPath, 0o755) err := os.MkdirAll(dirPath, 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755)
@@ -1433,7 +1314,7 @@ func TestScreenshotMarkdownRoute(t *testing.T) {
err := os.MkdirAll(dirPath, 0o755) err := os.MkdirAll(dirPath, 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755)
@@ -1468,7 +1349,7 @@ func TestScreenshotMarkdownRoute(t *testing.T) {
err := os.MkdirAll(dirPath, 0o755) err := os.MkdirAll(dirPath, 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("<div>{{ toHTML \"markdown.md\" }}</div>"), 0o755)
@@ -1547,6 +1428,7 @@ func TestConvertUrl(t *testing.T) {
api Api api Api
engine gotenberg.PdfEngine engine gotenberg.PdfEngine
options PdfOptions options PdfOptions
splitMode gotenberg.SplitMode
pdfFormats gotenberg.PdfFormats pdfFormats gotenberg.PdfFormats
metadata map[string]interface{} metadata map[string]interface{}
expectError bool expectError bool
@@ -1689,6 +1571,36 @@ func TestConvertUrl(t *testing.T) {
expectHttpError: false, expectHttpError: false,
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{
scenario: "PDF engine split error",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
engine: &gotenberg.PdfEngineMock{SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
}},
options: DefaultPdfOptions(),
splitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "success with split mode",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
engine: &gotenberg.PdfEngineMock{SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
}},
options: DefaultPdfOptions(),
splitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1"},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 1,
},
{ {
scenario: "PDF engine convert error", scenario: "PDF engine convert error",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
@@ -1719,6 +1631,27 @@ func TestConvertUrl(t *testing.T) {
expectHttpError: false, expectHttpError: false,
expectOutputPathsCount: 1, expectOutputPathsCount: 1,
}, },
{
scenario: "success with split mode and PDF formats",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
options: DefaultPdfOptions(),
splitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1"},
pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 1,
},
{ {
scenario: "PDF engine write metadata error", scenario: "PDF engine write metadata error",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
@@ -1778,7 +1711,13 @@ func TestConvertUrl(t *testing.T) {
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
err := convertUrl(tc.ctx.Context, tc.api, tc.engine, "", tc.options, tc.pdfFormats, tc.metadata) tc.ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
tc.ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
err := convertUrl(tc.ctx.Context, tc.api, tc.engine, "", tc.options, tc.splitMode, tc.pdfFormats, tc.metadata)
if tc.expectError && err == nil { if tc.expectError && err == nil {
t.Fatal("expected error but got none", err) t.Fatal("expected error but got none", err)

View File

@@ -5,7 +5,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"reflect" "reflect"
"strings"
"syscall"
"github.com/barasher/go-exiftool" "github.com/barasher/go-exiftool"
"go.uber.org/zap" "go.uber.org/zap"
@@ -53,11 +56,38 @@ func (engine *ExifTool) Validate() error {
return nil 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. // Merge is not available in this implementation.
func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { 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) 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. // Convert is not available in this implementation.
func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { 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) return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
@@ -151,5 +181,6 @@ var (
_ gotenberg.Module = (*ExifTool)(nil) _ gotenberg.Module = (*ExifTool)(nil)
_ gotenberg.Provisioner = (*ExifTool)(nil) _ gotenberg.Provisioner = (*ExifTool)(nil)
_ gotenberg.Validator = (*ExifTool)(nil) _ gotenberg.Validator = (*ExifTool)(nil)
_ gotenberg.Debuggable = (*ExifTool)(nil)
_ gotenberg.PdfEngine = (*ExifTool)(nil) _ gotenberg.PdfEngine = (*ExifTool)(nil)
) )

View File

@@ -73,6 +73,50 @@ func TestExifTool_Validate(t *testing.T) {
} }
} }
func TestExifTool_Debug(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *ExifTool
expect map[string]interface{}
doNotExpect map[string]interface{}
}{
{
scenario: "cannot determine version",
engine: &ExifTool{
binPath: "foo",
},
expect: map[string]interface{}{
"version": `exec: "foo": executable file not found in $PATH`,
},
},
{
scenario: "success",
engine: &ExifTool{
binPath: "echo",
},
doNotExpect: map[string]interface{}{
"version": `exec: "echo": executable file not found in $PATH`,
},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
d := tc.engine.Debug()
if tc.expect != nil {
if !reflect.DeepEqual(d, tc.expect) {
t.Errorf("expected '%v' but got '%v'", tc.expect, d)
}
}
if tc.doNotExpect != nil {
if reflect.DeepEqual(d, tc.doNotExpect) {
t.Errorf("did not expect '%v'", d)
}
}
})
}
}
func TestExiftool_Merge(t *testing.T) { func TestExiftool_Merge(t *testing.T) {
engine := new(ExifTool) engine := new(ExifTool)
err := engine.Merge(context.Background(), zap.NewNop(), nil, "") err := engine.Merge(context.Background(), zap.NewNop(), nil, "")
@@ -82,6 +126,24 @@ func TestExiftool_Merge(t *testing.T) {
} }
} }
func TestExiftool_Split(t *testing.T) {
engine := new(ExifTool)
_, err := engine.Split(context.Background(), zap.NewNop(), gotenberg.SplitMode{}, "", "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestExiftool_Flatten(t *testing.T) {
engine := new(ExifTool)
err := engine.Flatten(context.Background(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestExiftool_Convert(t *testing.T) { func TestExiftool_Convert(t *testing.T) {
engine := new(ExifTool) engine := new(ExifTool)
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
@@ -257,7 +319,7 @@ func TestExiftool_WriteMetadata(t *testing.T) {
var destinationPath string var destinationPath string
if tc.createCopy { if tc.createCopy {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll() outputDir, err := fs.MkdirAll()
if err != nil { if err != nil {
t.Fatalf("expected error no but got: %v", err) t.Fatalf("expected error no but got: %v", err)

View File

@@ -5,6 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"strings"
"syscall"
"time" "time"
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
@@ -305,6 +308,23 @@ func (a *Api) Stop(ctx context.Context) error {
return fmt.Errorf("stop LibreOffice: %w", err) 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. // Metrics returns the metrics.
func (a *Api) Metrics() ([]gotenberg.Metric, error) { func (a *Api) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{ return []gotenberg.Metric{
@@ -536,6 +556,7 @@ var (
_ gotenberg.Provisioner = (*Api)(nil) _ gotenberg.Provisioner = (*Api)(nil)
_ gotenberg.Validator = (*Api)(nil) _ gotenberg.Validator = (*Api)(nil)
_ gotenberg.App = (*Api)(nil) _ gotenberg.App = (*Api)(nil)
_ gotenberg.Debuggable = (*Api)(nil)
_ gotenberg.MetricsProvider = (*Api)(nil) _ gotenberg.MetricsProvider = (*Api)(nil)
_ api.HealthChecker = (*Api)(nil) _ api.HealthChecker = (*Api)(nil)
_ Uno = (*Api)(nil) _ Uno = (*Api)(nil)

View File

@@ -277,6 +277,54 @@ func TestApi_Stop(t *testing.T) {
} }
} }
func TestPdfTk_Debug(t *testing.T) {
for _, tc := range []struct {
scenario string
a *Api
expect map[string]interface{}
doNotExpect map[string]interface{}
}{
{
scenario: "cannot determine version",
a: &Api{
args: libreOfficeArguments{
binPath: "foo",
},
},
expect: map[string]interface{}{
"version": `exec: "foo": executable file not found in $PATH`,
},
},
{
scenario: "success",
a: &Api{
args: libreOfficeArguments{
binPath: "echo",
},
},
doNotExpect: map[string]interface{}{
"version": `exec: "echo": executable file not found in $PATH`,
},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
d := tc.a.Debug()
if tc.expect != nil {
if !reflect.DeepEqual(d, tc.expect) {
t.Errorf("expected '%v' but got '%v'", tc.expect, d)
}
}
if tc.doNotExpect != nil {
if reflect.DeepEqual(d, tc.doNotExpect) {
t.Errorf("did not expect '%v'", d)
}
}
})
}
}
func TestApi_Metrics(t *testing.T) { func TestApi_Metrics(t *testing.T) {
a := new(Api) a := new(Api)
a.supervisor = &gotenberg.ProcessSupervisorMock{ a.supervisor = &gotenberg.ProcessSupervisorMock{

View File

@@ -44,7 +44,7 @@ type libreOfficeProcess struct {
func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice { func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice {
p := &libreOfficeProcess{ p := &libreOfficeProcess{
arguments: arguments, arguments: arguments,
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
} }
p.isStarted.Store(false) p.isStarted.Store(false)
@@ -190,22 +190,23 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
// Always remove the user profile directory created by LibreOffice. // Always remove the user profile directory created by LibreOffice.
copyUserProfileDirPath := p.userProfileDirPath copyUserProfileDirPath := p.userProfileDirPath
defer func(userProfileDirPath string) { expirationTime := time.Now()
defer func(userProfileDirPath string, expirationTime time.Time) {
go func() { go func() {
err := os.RemoveAll(userProfileDirPath) err := os.RemoveAll(userProfileDirPath)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err)) 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. // Also remove LibreOffice specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}) err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime)
if err != nil { if err != nil {
logger.Error(err.Error()) logger.Error(err.Error())
} }
}() }()
}(copyUserProfileDirPath) }(copyUserProfileDirPath, expirationTime)
p.cfgMu.Lock() p.cfgMu.Lock()
defer p.cfgMu.Unlock() defer p.cfgMu.Unlock()

View File

@@ -230,7 +230,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
p.isStarted.Store(false) p.isStarted.Store(false)
return p return p
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
cancelledCtx: false, cancelledCtx: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -243,7 +243,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
p.isStarted.Store(true) p.isStarted.Store(true)
return p return p
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: "foo"}}, options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: "foo"}},
cancelledCtx: false, cancelledCtx: false,
start: false, start: false,
@@ -261,11 +261,11 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
), ),
options: Options{PageRanges: "foo"}, options: Options{PageRanges: "foo"},
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
@@ -291,40 +291,40 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
), ),
options: Options{Password: "foo"}, options: Options{Password: "foo"},
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
in, err := os.Open("/tests/test/testdata/libreoffice/protected.docx") in, err := os.Open("/tests/test/testdata/libreoffice/protected.docx")
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
defer func() { defer func() {
err := in.Close() err := in.Close()
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
}() }()
out, err := os.Create(fmt.Sprintf("%s/protected.docx", fs.WorkingDirPath())) out, err := os.Create(fmt.Sprintf("%s/protected.docx", fs.WorkingDirPath()))
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
defer func() { defer func() {
err := out.Close() err := out.Close()
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
}() }()
_, err = io.Copy(out, in) _, err = io.Copy(out, in)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
return fs return fs
@@ -344,11 +344,11 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
@@ -372,11 +372,11 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
@@ -400,11 +400,11 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
@@ -452,11 +452,11 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
@@ -481,11 +481,11 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
@@ -510,11 +510,11 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
@@ -539,11 +539,11 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
@@ -625,11 +625,11 @@ func TestNonBasicLatinCharactersGuard(t *testing.T) {
{ {
scenario: "basic latin characters", scenario: "basic latin characters",
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Basic latin characters"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Basic latin characters"), 0o755)
@@ -646,11 +646,11 @@ func TestNonBasicLatinCharactersGuard(t *testing.T) {
{ {
scenario: "non-basic latin characters", scenario: "non-basic latin characters",
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf("expected no error but got: %v", err)
} }
err = os.WriteFile(fmt.Sprintf("%s/éèßàùä.txt", fs.WorkingDirPath()), []byte("Non-basic latin characters"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/éèßàùä.txt", fs.WorkingDirPath()), []byte("Non-basic latin characters"), 0o755)

View File

@@ -51,6 +51,16 @@ func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logge
return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) 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 // Convert converts the given PDF to a specific PDF format. Currently, only the
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another // PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported] // PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]

View File

@@ -118,11 +118,30 @@ func TestLibreOfficePdfEngine_Merge(t *testing.T) {
} }
} }
func TestLibreOfficePdfEngine_Split(t *testing.T) {
engine := new(LibreOfficePdfEngine)
_, err := engine.Split(context.Background(), zap.NewNop(), gotenberg.SplitMode{}, "", "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_Flatten(t *testing.T) {
engine := new(LibreOfficePdfEngine)
err := engine.Flatten(context.Background(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_Convert(t *testing.T) { func TestLibreOfficePdfEngine_Convert(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
api api.Uno api api.Uno
expectError bool expectError bool
expectedError error
}{ }{
{ {
scenario: "convert success", scenario: "convert success",
@@ -134,13 +153,14 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) {
expectError: false, expectError: false,
}, },
{ {
scenario: "invalid PDF format", scenario: "ErrInvalidPdfFormats",
api: &api.ApiMock{ api: &api.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error { PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
return api.ErrInvalidPdfFormats return api.ErrInvalidPdfFormats
}, },
}, },
expectError: true, expectError: true,
expectedError: gotenberg.ErrPdfFormatNotSupported,
}, },
{ {
scenario: "convert fail", scenario: "convert fail",
@@ -163,6 +183,10 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) {
if tc.expectError && err == nil { if tc.expectError && err == nil {
t.Fatal("expected error but got none") 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)
}
}) })
} }
} }

View File

@@ -13,6 +13,7 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/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 // convertRoute returns an [api.Route] which can convert LibreOffice documents
@@ -26,7 +27,13 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
defaultOptions := libreofficeapi.DefaultOptions() defaultOptions := libreofficeapi.DefaultOptions()
// Let's get the data from the form and validate them. form := ctx.FormData()
splitMode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
zeroValuedSplitMode := gotenberg.SplitMode{}
var ( var (
inputPaths []string inputPaths []string
password string password string
@@ -51,14 +58,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
quality int quality int
reduceImageResolution bool reduceImageResolution bool
maxImageResolution int maxImageResolution int
pdfa string
pdfua bool
nativePdfFormats bool nativePdfFormats bool
merge bool merge bool
metadata map[string]interface{} flatten bool
) )
err := ctx.FormData(). err := form.
MandatoryPaths(libreOffice.Extensions(), &inputPaths). MandatoryPaths(libreOffice.Extensions(), &inputPaths).
String("password", &password, defaultOptions.Password). String("password", &password, defaultOptions.Password).
Bool("landscape", &landscape, defaultOptions.Landscape). Bool("landscape", &landscape, defaultOptions.Landscape).
@@ -120,8 +125,6 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
maxImageResolution = intValue maxImageResolution = intValue
return nil return nil
}). }).
String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false).
Bool("nativePdfFormats", &nativePdfFormats, true). Bool("nativePdfFormats", &nativePdfFormats, true).
Bool("merge", &merge, false). Bool("merge", &merge, false).
Custom("metadata", func(value string) error { Custom("metadata", func(value string) error {
@@ -133,17 +136,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
} }
return nil return nil
}). }).
Bool("flatten", &flatten, false).
Validate() Validate()
if err != nil { if err != nil {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
pdfFormats := gotenberg.PdfFormats{
PdfA: pdfa,
PdfUa: pdfua,
}
// Alright, let's convert each document to PDF.
outputPaths := make([]string, len(inputPaths)) outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths { for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf") outputPaths[i] = ctx.GeneratePath(".pdf")
@@ -172,7 +170,9 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
MaxImageResolution: maxImageResolution, MaxImageResolution: maxImageResolution,
} }
if nativePdfFormats { if nativePdfFormats && splitMode == zeroValuedSplitMode {
// Only apply natively given PDF formats if we're not
// splitting the PDF later.
options.PdfFormats = pdfFormats options.PdfFormats = pdfFormats
} }
@@ -206,11 +206,8 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
} }
} }
// So far so good, let's check if we have to merge the PDFs. if merge {
if len(outputPaths) > 1 && merge { outputPath, err := pdfengines.MergeStub(ctx, engine, outputPaths)
outputPath := ctx.GeneratePath(".pdf")
err = engine.Merge(ctx, ctx.Log(), outputPaths, outputPath)
if err != nil { if err != nil {
return fmt.Errorf("merge PDFs: %w", err) return fmt.Errorf("merge PDFs: %w", err)
} }
@@ -219,37 +216,61 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
outputPaths = []string{outputPath} outputPaths = []string{outputPath}
} }
// Let's check if the client want to convert each PDF to a specific if splitMode != zeroValuedSplitMode {
// PDF format. if !merge {
zeroValued := gotenberg.PdfFormats{} // document.docx -> document.docx.pdf, so that split naming
if !nativePdfFormats && pdfFormats != zeroValued { // document.docx_0.pdf, etc.
convertOutputPaths := make([]string, len(outputPaths)) for i, inputPath := range inputPaths {
outputPath := fmt.Sprintf("%s.pdf", inputPath)
for i, outputPath := range outputPaths { err = ctx.Rename(outputPaths[i], outputPath)
convertInputPath := outputPath if err != nil {
convertOutputPaths[i] = ctx.GeneratePath(".pdf") return fmt.Errorf("rename output path: %w", err)
}
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPaths[i]) outputPaths[i] = outputPath
if err != nil {
return fmt.Errorf("convert PDF: %w", err)
} }
} }
// Important: the output paths are now the converted files. outputPaths, err = pdfengines.SplitPdfStub(ctx, engine, splitMode, outputPaths)
outputPaths = convertOutputPaths if err != nil {
} return fmt.Errorf("split PDFs: %w", err)
// Writes and potentially overrides metadata entries, if any.
if len(metadata) > 0 {
for _, outputPath := range outputPaths {
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
} }
} }
if len(outputPaths) > 1 { if !nativePdfFormats || (nativePdfFormats && splitMode != zeroValuedSplitMode) {
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
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
}
}
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. // If .zip archive, document.docx -> document.docx.pdf.
for i, inputPath := range inputPaths { for i, inputPath := range inputPaths {
outputPath := fmt.Sprintf("%s.pdf", inputPath) outputPath := fmt.Sprintf("%s.pdf", inputPath)
@@ -263,8 +284,6 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
} }
} }
// Last but not least, add the output paths to the context so that
// the API is able to send them as a response to the client.
err = ctx.AddOutputPaths(outputPaths...) err = ctx.AddOutputPaths(outputPaths...)
if err != nil { if err != nil {
return fmt.Errorf("add output paths: %w", err) return fmt.Errorf("add output paths: %w", err)

View File

@@ -3,7 +3,10 @@ package libreoffice
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"net/http" "net/http"
"os"
"path/filepath"
"slices" "slices"
"testing" "testing"
@@ -300,6 +303,40 @@ func TestConvertRoute(t *testing.T) {
expectHttpError: false, expectHttpError: false,
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{
scenario: "PDF engine split error",
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{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
})
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{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{ {
scenario: "PDF engine convert error", scenario: "PDF engine convert error",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
@@ -366,17 +403,18 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{ {
scenario: "cannot rename many files", scenario: "PDF engine flatten error",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
"document.docx": "/document.docx", "document.docx": "/document.docx",
"document2.docx": "/document2.docx",
"document2.doc": "/document2.doc",
}) })
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { ctx.SetValues(map[string][]string{
return errors.New("cannot rename") "flatten": {
}}) "true",
},
})
ctx.SetCancelled(true)
return ctx return ctx
}(), }(),
libreOffice: &libreofficeapi.ApiMock{ libreOffice: &libreofficeapi.ApiMock{
@@ -384,7 +422,12 @@ func TestConvertRoute(t *testing.T) {
return nil return nil
}, },
ExtensionsMock: func() []string { ExtensionsMock: func() []string {
return []string{".docx", ".doc"} return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return errors.New("foo")
}, },
}, },
expectError: true, expectError: true,
@@ -443,6 +486,9 @@ func TestConvertRoute(t *testing.T) {
"metadata": { "metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }", "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
}, },
"flatten": {
"true",
},
}) })
return ctx return ctx
}(), }(),
@@ -464,6 +510,9 @@ func TestConvertRoute(t *testing.T) {
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil return nil
}, },
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
}, },
expectError: false, expectError: false,
expectHttpError: false, expectHttpError: false,
@@ -491,6 +540,9 @@ func TestConvertRoute(t *testing.T) {
"metadata": { "metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }", "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
}, },
"flatten": {
"true",
},
}) })
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil return nil
@@ -515,6 +567,9 @@ func TestConvertRoute(t *testing.T) {
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil return nil
}, },
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
}, },
expectError: false, expectError: false,
expectHttpError: false, expectHttpError: false,
@@ -550,9 +605,173 @@ func TestConvertRoute(t *testing.T) {
expectHttpError: false, expectHttpError: false,
expectOutputPathsCount: 1, expectOutputPathsCount: 1,
}, },
{
scenario: "success with split (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",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
})
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
},
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
return []string{
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 0,
),
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 1,
),
}, nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 4,
expectOutputPaths: []string{"/document_docx/document.docx_0.pdf", "/document_docx/document.docx_1.pdf", "/document2_docx/document2.docx_0.pdf", "/document2_docx/document2.docx_1.pdf"},
},
{
scenario: "success with merge and split",
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",
},
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
})
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
},
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
return []string{
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 0,
),
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 1,
),
}, nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 2,
},
{
scenario: "success with split and native PDF/A & PDF/UA (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",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
"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{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
return []string{
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 0,
),
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 1,
),
}, nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 4,
expectOutputPaths: []string{"/document_docx/document.docx_0.pdf", "/document_docx/document.docx_1.pdf", "/document2_docx/document2.docx_0.pdf", "/document2_docx/document2.docx_1.pdf"},
},
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
tc.ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
tc.ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
c := echo.New().NewContext(nil, nil) c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context) c.Set("context", tc.ctx.Context)

View File

@@ -2,6 +2,7 @@
// interface using the pdfcpu command-line tool. This package allows for: // interface using the pdfcpu command-line tool. This package allows for:
// //
// 1. The merging of PDF files. // 1. The merging of PDF files.
// 2. The splitting of PDF files.
// //
// See: https://github.com/pdfcpu/pdfcpu. // See: https://github.com/pdfcpu/pdfcpu.
package pdfcpu package pdfcpu

View File

@@ -5,6 +5,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
"go.uber.org/zap" "go.uber.org/zap"
@@ -51,6 +56,32 @@ func (engine *PdfCpu) Validate() error {
return nil return nil
} }
// Debug returns additional debug data.
func (engine *PdfCpu) Debug() map[string]interface{} {
debug := make(map[string]interface{})
cmd := exec.Command(engine.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"] = "Unable to determine pdfcpu version"
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "pdfcpu:") {
debug["version"] = strings.TrimSpace(strings.TrimPrefix(line, "pdfcpu:"))
break
}
}
return debug
}
// Merge combines multiple PDFs into a single PDF. // Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string var args []string
@@ -70,6 +101,61 @@ func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths
return fmt.Errorf("merge PDFs with pdfcpu: %w", err) return fmt.Errorf("merge PDFs with pdfcpu: %w", err)
} }
// Split splits a given PDF file.
func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var args []string
switch mode.Mode {
case gotenberg.SplitModeIntervals:
args = append(args, "split", "-mode", "span", inputPath, outputDirPath, mode.Span)
case gotenberg.SplitModePages:
if mode.Unify {
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
args = append(args, "trim", "-pages", mode.Span, inputPath, outputPath)
break
}
args = append(args, "extract", "-mode", "page", "-pages", mode.Span, inputPath, outputDirPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with pdfcpu: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("split PDFs with pdfcpu: %w", err)
}
var outputPaths []string
err = filepath.Walk(outputDirPath, func(path string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
if info.IsDir() {
return nil
}
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
outputPaths = append(outputPaths, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walk directory to find resulting PDFs from split with pdfcpu: %w", err)
}
sort.Sort(gotenberg.AlphanumericSort(outputPaths))
return outputPaths, nil
}
// Flatten is not available in this implementation.
func (engine *PdfCpu) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
return fmt.Errorf("flatten PDF with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Convert is not available in this implementation. // Convert is not available in this implementation.
func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
@@ -90,5 +176,6 @@ var (
_ gotenberg.Module = (*PdfCpu)(nil) _ gotenberg.Module = (*PdfCpu)(nil)
_ gotenberg.Provisioner = (*PdfCpu)(nil) _ gotenberg.Provisioner = (*PdfCpu)(nil)
_ gotenberg.Validator = (*PdfCpu)(nil) _ gotenberg.Validator = (*PdfCpu)(nil)
_ gotenberg.Debuggable = (*PdfCpu)(nil)
_ gotenberg.PdfEngine = (*PdfCpu)(nil) _ gotenberg.PdfEngine = (*PdfCpu)(nil)
) )

View File

@@ -71,6 +71,59 @@ func TestPdfCpu_Validate(t *testing.T) {
} }
} }
func TestPdfCpu_Debug(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *PdfCpu
expect map[string]interface{}
doNotExpect map[string]interface{}
}{
{
scenario: "cannot determine version (command error)",
engine: &PdfCpu{
binPath: "foo",
},
expect: map[string]interface{}{
"version": `exec: "foo": executable file not found in $PATH`,
},
},
{
scenario: "cannot determine version (no pdfcpu)",
engine: &PdfCpu{
binPath: "echo",
},
expect: map[string]interface{}{
"version": "Unable to determine pdfcpu version",
},
},
{
scenario: "success",
engine: &PdfCpu{
binPath: "pdfcpu",
},
doNotExpect: map[string]interface{}{
"version": "Unable to determine pdfcpu version",
},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
d := tc.engine.Debug()
if tc.expect != nil {
if !reflect.DeepEqual(d, tc.expect) {
t.Errorf("expected '%v' but got '%v'", tc.expect, d)
}
}
if tc.doNotExpect != nil {
if reflect.DeepEqual(d, tc.doNotExpect) {
t.Errorf("did not expect '%v'", d)
}
}
})
}
}
func TestPdfCpu_Merge(t *testing.T) { func TestPdfCpu_Merge(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
@@ -116,7 +169,7 @@ func TestPdfCpu_Merge(t *testing.T) {
t.Fatalf("expected error but got: %v", err) t.Fatalf("expected error but got: %v", err)
} }
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll() outputDir, err := fs.MkdirAll()
if err != nil { if err != nil {
t.Fatalf("expected error but got: %v", err) t.Fatalf("expected error but got: %v", err)
@@ -142,6 +195,112 @@ func TestPdfCpu_Merge(t *testing.T) {
} }
} }
func TestPdfCpu_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
mode gotenberg.SplitMode
inputPath string
expectError bool
expectedError error
expectOutputPathsCount int
}{
{
scenario: "ErrPdfSplitModeNotSupported",
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "invalid context",
ctx: nil,
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "success (intervals)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
inputPath: "/tests/test/testdata/pdfengines/sample4.pdf",
expectError: false,
expectOutputPathsCount: 20,
},
{
scenario: "success (pages)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1"},
inputPath: "/tests/test/testdata/pdfengines/sample4.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
{
scenario: "success (pages & unify)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample4.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfCpu)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error 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)
}
}()
outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir)
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)
}
if tc.expectOutputPathsCount != len(outputPaths) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths))
}
})
}
}
func TestPdfCpu_Flatten(t *testing.T) {
mod := new(PdfCpu)
err := mod.Flatten(context.TODO(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestPdfCpu_Convert(t *testing.T) { func TestPdfCpu_Convert(t *testing.T) {
mod := new(PdfCpu) mod := new(PdfCpu)
err := mod.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") err := mod.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")

View File

@@ -12,23 +12,29 @@ import (
) )
type multiPdfEngines struct { type multiPdfEngines struct {
mergeEngines []gotenberg.PdfEngine mergeEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine splitEngines []gotenberg.PdfEngine
readMedataEngines []gotenberg.PdfEngine flattenEngines []gotenberg.PdfEngine
writeMedataEngines []gotenberg.PdfEngine convertEngines []gotenberg.PdfEngine
readMetadataEngines []gotenberg.PdfEngine
writeMetadataEngines []gotenberg.PdfEngine
} }
func newMultiPdfEngines( func newMultiPdfEngines(
mergeEngines, mergeEngines,
splitEngines,
flattenEngines,
convertEngines, convertEngines,
readMetadataEngines, readMetadataEngines,
writeMedataEngines []gotenberg.PdfEngine, writeMetadataEngines []gotenberg.PdfEngine,
) *multiPdfEngines { ) *multiPdfEngines {
return &multiPdfEngines{ return &multiPdfEngines{
mergeEngines: mergeEngines, mergeEngines: mergeEngines,
convertEngines: convertEngines, splitEngines: splitEngines,
readMedataEngines: readMetadataEngines, flattenEngines: flattenEngines,
writeMedataEngines: writeMedataEngines, convertEngines: convertEngines,
readMetadataEngines: readMetadataEngines,
writeMetadataEngines: writeMetadataEngines,
} }
} }
@@ -57,6 +63,67 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
return fmt.Errorf("merge PDFs with multi PDF engines: %w", err) return fmt.Errorf("merge PDFs with multi PDF engines: %w", err)
} }
type splitResult struct {
outputPaths []string
err error
}
// Split tries to split at intervals a given PDF thanks to its children. If the
// context is done, it stops and returns an error.
func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var err error
var mu sync.Mutex // to safely append errors.
for _, engine := range multi.splitEngines {
resultChan := make(chan splitResult, 1)
go func(engine gotenberg.PdfEngine) {
outputPaths, err := engine.Split(ctx, logger, mode, inputPath, outputDirPath)
resultChan <- splitResult{outputPaths: outputPaths, err: err}
}(engine)
select {
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = multierr.Append(err, result.err)
mu.Unlock()
} else {
return result.outputPaths, nil
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err)
}
// Flatten merges existing annotation appearances with page content, thanks to
// its children. If the context is done, it stops and returns an error
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.flattenEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Flatten(ctx, logger, inputPath)
}(engine)
select {
case mergeErr := <-errChan:
errored := multierr.AppendInto(&err, mergeErr)
if !errored {
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
}
// Convert converts the given PDF to a specific PDF format. thanks to its // Convert converts the given PDF to a specific PDF format. thanks to its
// children. If the context is done, it stops and returns an error. // children. If the context is done, it stops and returns an error.
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
@@ -91,16 +158,14 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
var err error var err error
var mu sync.Mutex // to safely append errors. var mu sync.Mutex // to safely append errors.
resultChan := make(chan readMetadataResult, len(multi.readMedataEngines)) for _, engine := range multi.readMetadataEngines {
resultChan := make(chan readMetadataResult, 1)
for _, engine := range multi.readMedataEngines {
go func(engine gotenberg.PdfEngine) { go func(engine gotenberg.PdfEngine) {
metadata, err := engine.ReadMetadata(ctx, logger, inputPath) metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
resultChan <- readMetadataResult{metadata: metadata, err: err} resultChan <- readMetadataResult{metadata: metadata, err: err}
}(engine) }(engine)
}
for range multi.readMedataEngines {
select { select {
case result := <-resultChan: case result := <-resultChan:
if result.err != nil { if result.err != nil {
@@ -122,7 +187,7 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log
var err error var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
for _, engine := range multi.writeMedataEngines { for _, engine := range multi.writeMetadataEngines {
go func(engine gotenberg.PdfEngine) { go func(engine gotenberg.PdfEngine) {
errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath) errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath)
}(engine) }(engine)

View File

@@ -19,25 +19,22 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
}{ }{
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
[]gotenberg.PdfEngine{ mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil return nil
}, },
}, },
}, },
nil, },
nil,
nil,
),
ctx: context.Background(), ctx: context.Background(),
expectError: false, expectError: false,
}, },
{ {
scenario: "at least one engine does not return an error", scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
[]gotenberg.PdfEngine{ mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo") return errors.New("foo")
@@ -49,17 +46,14 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
}, },
}, },
}, },
nil, },
nil,
nil,
),
ctx: context.Background(), ctx: context.Background(),
expectError: false, expectError: false,
}, },
{ {
scenario: "all engines return an error", scenario: "all engines return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
[]gotenberg.PdfEngine{ mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo") return errors.New("foo")
@@ -71,27 +65,21 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
}, },
}, },
}, },
nil, },
nil,
nil,
),
ctx: context.Background(), ctx: context.Background(),
expectError: true, expectError: true,
}, },
{ {
scenario: "context expired", scenario: "context expired",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
[]gotenberg.PdfEngine{ mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil return nil
}, },
}, },
}, },
nil, },
nil,
nil,
),
ctx: func() context.Context { ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
@@ -115,6 +103,188 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
} }
} }
func TestMultiPdfEngines_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
_, err := tc.engine.Split(tc.ctx, zap.NewNop(), gotenberg.SplitMode{}, "", "")
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 TestMultiPdfEngines_Flatten(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
flattenEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
flattenEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
flattenEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
flattenEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Flatten(tc.ctx, zap.NewNop(), "")
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 TestMultiPdfEngines_Convert(t *testing.T) { func TestMultiPdfEngines_Convert(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
@@ -124,25 +294,21 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
}{ }{
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, convertEngines: []gotenberg.PdfEngine{
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil return nil
}, },
}, },
}, },
nil, },
nil,
),
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "at least one engine does not return an error", scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, convertEngines: []gotenberg.PdfEngine{
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo") return errors.New("foo")
@@ -154,16 +320,13 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
}, },
}, },
}, },
nil, },
nil,
),
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "all engines return an error", scenario: "all engines return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, convertEngines: []gotenberg.PdfEngine{
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo") return errors.New("foo")
@@ -175,26 +338,21 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
}, },
}, },
}, },
nil, },
nil,
),
ctx: context.Background(), ctx: context.Background(),
expectError: true, expectError: true,
}, },
{ {
scenario: "context expired", scenario: "context expired",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, convertEngines: []gotenberg.PdfEngine{
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil return nil
}, },
}, },
}, },
nil, },
nil,
),
ctx: func() context.Context { ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
@@ -227,26 +385,21 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
}{ }{
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, readMetadataEngines: []gotenberg.PdfEngine{
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil return make(map[string]interface{}), nil
}, },
}, },
}, },
nil, },
),
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "at least one engine does not return an error", scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, readMetadataEngines: []gotenberg.PdfEngine{
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo") return nil, errors.New("foo")
@@ -258,16 +411,13 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
}, },
}, },
}, },
nil, },
),
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "all engines return an error", scenario: "all engines return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, readMetadataEngines: []gotenberg.PdfEngine{
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo") return nil, errors.New("foo")
@@ -279,25 +429,21 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
}, },
}, },
}, },
nil, },
),
ctx: context.Background(), ctx: context.Background(),
expectError: true, expectError: true,
}, },
{ {
scenario: "context expired", scenario: "context expired",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, readMetadataEngines: []gotenberg.PdfEngine{
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil return make(map[string]interface{}), nil
}, },
}, },
}, },
nil, },
),
ctx: func() context.Context { ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
@@ -330,27 +476,21 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
}{ }{
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, writeMetadataEngines: []gotenberg.PdfEngine{
nil,
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil return nil
}, },
}, },
}, },
), },
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "at least one engine does not return an error", scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, writeMetadataEngines: []gotenberg.PdfEngine{
nil,
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo") return errors.New("foo")
@@ -362,16 +502,13 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
}, },
}, },
}, },
), },
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "all engines return an error", scenario: "all engines return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, writeMetadataEngines: []gotenberg.PdfEngine{
nil,
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo") return errors.New("foo")
@@ -383,24 +520,21 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
}, },
}, },
}, },
), },
ctx: context.Background(), ctx: context.Background(),
expectError: true, expectError: true,
}, },
{ {
scenario: "context expired", scenario: "context expired",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, writeMetadataEngines: []gotenberg.PdfEngine{
nil,
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil return nil
}, },
}, },
}, },
), },
ctx: func() context.Context { ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()

View File

@@ -27,12 +27,14 @@ func init() {
// the [api.Router] interface to expose relevant PDF processing routes if // the [api.Router] interface to expose relevant PDF processing routes if
// enabled. // enabled.
type PdfEngines struct { type PdfEngines struct {
mergeNames []string mergeNames []string
convertNames []string splitNames []string
readMetadataNames []string flattenNames []string
writeMedataNames []string convertNames []string
engines []gotenberg.PdfEngine readMetadataNames []string
disableRoutes bool writeMetadataNames []string
engines []gotenberg.PdfEngine
disableRoutes bool
} }
// Descriptor returns a PdfEngines' module descriptor. // Descriptor returns a PdfEngines' module descriptor.
@@ -42,6 +44,8 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
FlagSet: func() *flag.FlagSet { FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("pdfengines", flag.ExitOnError) fs := flag.NewFlagSet("pdfengines", flag.ExitOnError)
fs.StringSlice("pdfengines-merge-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the merge feature - empty means all") fs.StringSlice("pdfengines-merge-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the merge feature - empty means all")
fs.StringSlice("pdfengines-split-engines", []string{"pdfcpu", "qpdf", "pdftk"}, "Set the PDF engines and their order for the split feature - empty means all")
fs.StringSlice("pdfengines-flatten-engines", []string{"qpdf"}, "Set the PDF engines and their order for the flatten feature - empty means all")
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all") fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all") fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all") fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
@@ -64,6 +68,8 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags() flags := ctx.ParsedFlags()
mergeNames := flags.MustStringSlice("pdfengines-merge-engines") mergeNames := flags.MustStringSlice("pdfengines-merge-engines")
splitNames := flags.MustStringSlice("pdfengines-split-engines")
flattenNames := flags.MustStringSlice("pdfengines-flatten-engines")
convertNames := flags.MustStringSlice("pdfengines-convert-engines") convertNames := flags.MustStringSlice("pdfengines-convert-engines")
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines") readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines") writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
@@ -98,6 +104,16 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.mergeNames = mergeNames mod.mergeNames = mergeNames
} }
mod.splitNames = defaultNames
if len(splitNames) > 0 {
mod.splitNames = splitNames
}
mod.flattenNames = defaultNames
if len(flattenNames) > 0 {
mod.flattenNames = flattenNames
}
mod.convertNames = defaultNames mod.convertNames = defaultNames
if len(convertNames) > 0 { if len(convertNames) > 0 {
mod.convertNames = convertNames mod.convertNames = convertNames
@@ -108,9 +124,9 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.readMetadataNames = readMetadataNames mod.readMetadataNames = readMetadataNames
} }
mod.writeMedataNames = defaultNames mod.writeMetadataNames = defaultNames
if len(writeMetadataNames) > 0 { if len(writeMetadataNames) > 0 {
mod.writeMedataNames = writeMetadataNames mod.writeMetadataNames = writeMetadataNames
} }
return nil return nil
@@ -161,9 +177,11 @@ func (mod *PdfEngines) Validate() error {
} }
findNonExistingEngines(mod.mergeNames) findNonExistingEngines(mod.mergeNames)
findNonExistingEngines(mod.splitNames)
findNonExistingEngines(mod.flattenNames)
findNonExistingEngines(mod.convertNames) findNonExistingEngines(mod.convertNames)
findNonExistingEngines(mod.readMetadataNames) findNonExistingEngines(mod.readMetadataNames)
findNonExistingEngines(mod.writeMedataNames) findNonExistingEngines(mod.writeMetadataNames)
if len(nonExistingEngines) == 0 { if len(nonExistingEngines) == 0 {
return nil return nil
@@ -177,9 +195,11 @@ func (mod *PdfEngines) Validate() error {
func (mod *PdfEngines) SystemMessages() []string { func (mod *PdfEngines) SystemMessages() []string {
return []string{ return []string{
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")), fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames[:], " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")), fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")), fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")), fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
} }
} }
@@ -201,9 +221,11 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
return newMultiPdfEngines( return newMultiPdfEngines(
engines(mod.mergeNames), engines(mod.mergeNames),
engines(mod.splitNames),
engines(mod.flattenNames),
engines(mod.convertNames), engines(mod.convertNames),
engines(mod.readMetadataNames), engines(mod.readMetadataNames),
engines(mod.writeMedataNames), engines(mod.writeMetadataNames),
), nil ), nil
} }
@@ -222,6 +244,8 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
return []api.Route{ return []api.Route{
mergeRoute(engine), mergeRoute(engine),
splitRoute(engine),
flattenRoute(engine),
convertRoute(engine), convertRoute(engine),
readMetadataRoute(engine), readMetadataRoute(engine),
writeMetadataRoute(engine), writeMetadataRoute(engine),

View File

@@ -26,6 +26,8 @@ func TestPdfEngines_Provision(t *testing.T) {
scenario string scenario string
ctx *gotenberg.Context ctx *gotenberg.Context
expectedMergePdfEngines []string expectedMergePdfEngines []string
expectedSplitPdfEngines []string
expectedFlattenPdfEngines []string
expectedConvertPdfEngines []string expectedConvertPdfEngines []string
expectedReadMetadataPdfEngines []string expectedReadMetadataPdfEngines []string
expectedWriteMetadataPdfEngines []string expectedWriteMetadataPdfEngines []string
@@ -66,6 +68,8 @@ func TestPdfEngines_Provision(t *testing.T) {
) )
}(), }(),
expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"}, expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"},
expectedSplitPdfEngines: []string{"pdfcpu", "qpdf", "pdftk"},
expectedFlattenPdfEngines: []string{"qpdf"},
expectedConvertPdfEngines: []string{"libreoffice-pdfengine"}, expectedConvertPdfEngines: []string{"libreoffice-pdfengine"},
expectedReadMetadataPdfEngines: []string{"exiftool"}, expectedReadMetadataPdfEngines: []string{"exiftool"},
expectedWriteMetadataPdfEngines: []string{"exiftool"}, expectedWriteMetadataPdfEngines: []string{"exiftool"},
@@ -107,7 +111,7 @@ func TestPdfEngines_Provision(t *testing.T) {
} }
fs := new(PdfEngines).Descriptor().FlagSet fs := new(PdfEngines).Descriptor().FlagSet
err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"}) err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-split-engines=a", "--pdfengines-flatten-engines=c", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"})
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
@@ -125,6 +129,8 @@ func TestPdfEngines_Provision(t *testing.T) {
}(), }(),
expectedMergePdfEngines: []string{"b"}, expectedMergePdfEngines: []string{"b"},
expectedSplitPdfEngines: []string{"a"},
expectedFlattenPdfEngines: []string{"c"},
expectedConvertPdfEngines: []string{"b"}, expectedConvertPdfEngines: []string{"b"},
expectedReadMetadataPdfEngines: []string{"a"}, expectedReadMetadataPdfEngines: []string{"a"},
expectedWriteMetadataPdfEngines: []string{"a"}, expectedWriteMetadataPdfEngines: []string{"a"},
@@ -182,6 +188,10 @@ func TestPdfEngines_Provision(t *testing.T) {
t.Fatalf("expected %d merge names but got %d", len(tc.expectedMergePdfEngines), len(mod.mergeNames)) t.Fatalf("expected %d merge names but got %d", len(tc.expectedMergePdfEngines), len(mod.mergeNames))
} }
if len(tc.expectedFlattenPdfEngines) != len(mod.flattenNames) {
t.Fatalf("expected %d flatten names but got %d", len(tc.expectedFlattenPdfEngines), len(mod.flattenNames))
}
if len(tc.expectedConvertPdfEngines) != len(mod.convertNames) { if len(tc.expectedConvertPdfEngines) != len(mod.convertNames) {
t.Fatalf("expected %d convert names but got %d", len(tc.expectedConvertPdfEngines), len(mod.convertNames)) t.Fatalf("expected %d convert names but got %d", len(tc.expectedConvertPdfEngines), len(mod.convertNames))
} }
@@ -190,8 +200,8 @@ func TestPdfEngines_Provision(t *testing.T) {
t.Fatalf("expected %d read metadata names but got %d", len(tc.expectedReadMetadataPdfEngines), len(mod.readMetadataNames)) t.Fatalf("expected %d read metadata names but got %d", len(tc.expectedReadMetadataPdfEngines), len(mod.readMetadataNames))
} }
if len(tc.expectedWriteMetadataPdfEngines) != len(mod.writeMedataNames) { if len(tc.expectedWriteMetadataPdfEngines) != len(mod.writeMetadataNames) {
t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMedataNames)) t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMetadataNames))
} }
for index, name := range mod.mergeNames { for index, name := range mod.mergeNames {
@@ -200,6 +210,12 @@ func TestPdfEngines_Provision(t *testing.T) {
} }
} }
for index, name := range mod.splitNames {
if name != tc.expectedSplitPdfEngines[index] {
t.Fatalf("expected split name at index %d to be %s, but got: %s", index, name, tc.expectedSplitPdfEngines[index])
}
}
for index, name := range mod.convertNames { for index, name := range mod.convertNames {
if name != tc.expectedConvertPdfEngines[index] { if name != tc.expectedConvertPdfEngines[index] {
t.Fatalf("expected convert name at index %d to be %s, but got: %s", index, name, tc.expectedConvertPdfEngines[index]) t.Fatalf("expected convert name at index %d to be %s, but got: %s", index, name, tc.expectedConvertPdfEngines[index])
@@ -212,7 +228,7 @@ func TestPdfEngines_Provision(t *testing.T) {
} }
} }
for index, name := range mod.writeMedataNames { for index, name := range mod.writeMetadataNames {
if name != tc.expectedWriteMetadataPdfEngines[index] { if name != tc.expectedWriteMetadataPdfEngines[index] {
t.Fatalf("expected write metadat name at index %d to be %s, but got: %s", index, name, tc.expectedWriteMetadataPdfEngines[index]) t.Fatalf("expected write metadat name at index %d to be %s, but got: %s", index, name, tc.expectedWriteMetadataPdfEngines[index])
} }
@@ -280,11 +296,11 @@ func TestPdfEngines_Validate(t *testing.T) {
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
mod := PdfEngines{ mod := PdfEngines{
mergeNames: tc.names, mergeNames: tc.names,
convertNames: tc.names, convertNames: tc.names,
readMetadataNames: tc.names, readMetadataNames: tc.names,
writeMedataNames: tc.names, writeMetadataNames: tc.names,
engines: tc.engines, engines: tc.engines,
} }
err := mod.Validate() err := mod.Validate()
@@ -303,20 +319,24 @@ func TestPdfEngines_Validate(t *testing.T) {
func TestPdfEngines_SystemMessages(t *testing.T) { func TestPdfEngines_SystemMessages(t *testing.T) {
mod := new(PdfEngines) mod := new(PdfEngines)
mod.mergeNames = []string{"foo", "bar"} mod.mergeNames = []string{"foo", "bar"}
mod.splitNames = []string{"foo", "bar"}
mod.convertNames = []string{"foo", "bar"} mod.convertNames = []string{"foo", "bar"}
mod.readMetadataNames = []string{"foo", "bar"} mod.readMetadataNames = []string{"foo", "bar"}
mod.writeMedataNames = []string{"foo", "bar"} mod.writeMetadataNames = []string{"foo", "bar"}
expectedMessages := 6
messages := mod.SystemMessages() messages := mod.SystemMessages()
if len(messages) != 4 { if len(messages) != expectedMessages {
t.Errorf("expected one and only one message, but got %d", len(messages)) t.Errorf("expected %d message(s), but got %d", expectedMessages, len(messages))
} }
expect := []string{ expect := []string{
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")), fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames[:], " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")), fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")), fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")), fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
} }
for i, message := range messages { for i, message := range messages {
@@ -328,10 +348,11 @@ func TestPdfEngines_SystemMessages(t *testing.T) {
func TestPdfEngines_PdfEngine(t *testing.T) { func TestPdfEngines_PdfEngine(t *testing.T) {
mod := PdfEngines{ mod := PdfEngines{
mergeNames: []string{"foo", "bar"}, mergeNames: []string{"foo", "bar"},
convertNames: []string{"foo", "bar"}, splitNames: []string{"foo", "bar"},
readMetadataNames: []string{"foo", "bar"}, convertNames: []string{"foo", "bar"},
writeMedataNames: []string{"foo", "bar"}, readMetadataNames: []string{"foo", "bar"},
writeMetadataNames: []string{"foo", "bar"},
engines: func() []gotenberg.PdfEngine { engines: func() []gotenberg.PdfEngine {
engine1 := &struct { engine1 := &struct {
gotenberg.ModuleMock gotenberg.ModuleMock
@@ -370,7 +391,7 @@ func TestPdfEngines_Routes(t *testing.T) {
}{ }{
{ {
scenario: "routes not disabled", scenario: "routes not disabled",
expectRoutes: 4, expectRoutes: 6,
disableRoutes: false, disableRoutes: false,
}, },
{ {

View File

@@ -6,6 +6,8 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strconv"
"strings"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
@@ -13,6 +15,245 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
) )
// FormDataPdfSplitMode creates a [gotenberg.SplitMode] from the form data.
func FormDataPdfSplitMode(form *api.FormData, mandatory bool) gotenberg.SplitMode {
var (
mode string
span string
unify bool
)
splitModeFunc := func(value string) error {
if value != "" && value != gotenberg.SplitModeIntervals && value != gotenberg.SplitModePages {
return fmt.Errorf("wrong value, expected either '%s' or '%s'", gotenberg.SplitModeIntervals, gotenberg.SplitModePages)
}
mode = value
return nil
}
splitSpanFunc := func(value string) error {
value = strings.Join(strings.Fields(value), "")
if mode == gotenberg.SplitModeIntervals {
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 1 {
return errors.New("value is inferior to 1")
}
}
span = value
return nil
}
if mandatory {
form.
MandatoryCustom("splitMode", func(value string) error {
return splitModeFunc(value)
}).
MandatoryCustom("splitSpan", func(value string) error {
return splitSpanFunc(value)
})
} else {
form.
Custom("splitMode", func(value string) error {
return splitModeFunc(value)
}).
Custom("splitSpan", func(value string) error {
return splitSpanFunc(value)
})
}
form.
Bool("splitUnify", &unify, false).
Custom("splitUnify", func(value string) error {
if value != "" && unify && mode != gotenberg.SplitModePages {
return fmt.Errorf("unify is not available for split mode '%s'", mode)
}
return nil
})
return gotenberg.SplitMode{
Mode: mode,
Span: span,
Unify: unify,
}
}
// FormDataPdfFormats creates [gotenberg.PdfFormats] from the form data.
// Fallback to default value if the considered key is not present.
func FormDataPdfFormats(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,
}
}
// FormDataPdfMetadata creates metadata object from the form data.
func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]interface{} {
var metadata map[string]interface{}
metadataFunc := func(value string) error {
if len(value) > 0 {
err := json.Unmarshal([]byte(value), &metadata)
if err != nil {
return fmt.Errorf("unmarshal metadata: %w", err)
}
}
return nil
}
if mandatory {
form.MandatoryCustom("metadata", func(value string) error {
return metadataFunc(value)
})
} else {
form.Custom("metadata", func(value string) error {
return metadataFunc(value)
})
}
return metadata
}
// MergeStub merges given PDFs. If only one input PDF, it does nothing and
// returns the corresponding input path.
func MergeStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) (string, error) {
if len(inputPaths) == 0 {
return "", errors.New("no input paths")
}
if len(inputPaths) == 1 {
return inputPaths[0], nil
}
outputPath := ctx.GeneratePath(".pdf")
err := engine.Merge(ctx, ctx.Log(), inputPaths, outputPath)
if err != nil {
return "", fmt.Errorf("merge %d PDFs: %w", len(inputPaths), err)
}
return outputPath, nil
}
// SplitPdfStub splits a list of PDF files based on [gotenberg.SplitMode].
// It returns a list of output paths or the list of provided input paths if no
// split requested.
func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.SplitMode, inputPaths []string) ([]string, error) {
zeroValued := gotenberg.SplitMode{}
if mode == zeroValued {
return inputPaths, nil
}
var outputPaths []string
for _, inputPath := range inputPaths {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
outputDirPath, err := ctx.CreateSubDirectory(strings.ReplaceAll(filepath.Base(filenameNoExt), ".", "_"))
if err != nil {
return nil, fmt.Errorf("create subdirectory from input path: %w", err)
}
paths, err := engine.Split(ctx, ctx.Log(), mode, inputPath, outputDirPath)
if err != nil {
return nil, fmt.Errorf("split PDF '%s': %w", inputPath, err)
}
// Keep the original filename.
for i, path := range paths {
var newPath string
if mode.Unify && mode.Mode == gotenberg.SplitModePages {
newPath = fmt.Sprintf(
"%s/%s.pdf",
outputDirPath, filenameNoExt,
)
} else {
newPath = fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, i,
)
}
err = ctx.Rename(path, newPath)
if err != nil {
return nil, fmt.Errorf("rename path: %w", err)
}
outputPaths = append(outputPaths, newPath)
if mode.Unify && mode.Mode == gotenberg.SplitModePages {
break
}
}
}
return outputPaths, nil
}
// FlattenStub merges annotation appearances with page content for each given
// PDF, effectively deleting the original annotations.
func FlattenStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) error {
for _, inputPath := range inputPaths {
err := engine.Flatten(ctx, ctx.Log(), inputPath)
if err != nil {
return fmt.Errorf("flatten '%s': %w", inputPath, err)
}
}
return nil
}
// ConvertStub transforms a given PDF to the specified formats defined in
// [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
// paths.
func ConvertStub(ctx *api.Context, engine gotenberg.PdfEngine, formats gotenberg.PdfFormats, inputPaths []string) ([]string, error) {
zeroValued := gotenberg.PdfFormats{}
if formats == zeroValued {
return inputPaths, nil
}
outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf")
err := engine.Convert(ctx, ctx.Log(), formats, inputPath, outputPaths[i])
if err != nil {
return nil, fmt.Errorf("convert '%s': %w", inputPath, err)
}
}
return outputPaths, nil
}
// WriteMetadataStub writes the metadata into PDF files. If no metadata, it
// does nothing.
func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata map[string]interface{}, inputPaths []string) error {
if len(metadata) == 0 {
return nil
}
for _, inputPath := range inputPaths {
err := engine.WriteMetadata(ctx, ctx.Log(), metadata, inputPath)
if err != nil {
return fmt.Errorf("write metadata into '%s': %w", inputPath, err)
}
}
return nil
}
// mergeRoute returns an [api.Route] which can merge PDFs. // mergeRoute returns an [api.Route] which can merge PDFs.
func mergeRoute(engine gotenberg.PdfEngine) api.Route { func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{ return api.Route{
@@ -22,75 +263,148 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
// Let's get the data from the form and validate them. form := ctx.FormData()
var ( pdfFormats := FormDataPdfFormats(form)
inputPaths []string metadata := FormDataPdfMetadata(form, false)
pdfa string
pdfua bool
metadata map[string]interface{}
)
err := ctx.FormData(). var inputPaths []string
var flatten bool
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths). MandatoryPaths([]string{".pdf"}, &inputPaths).
String("pdfa", &pdfa, ""). Bool("flatten", &flatten, false).
Bool("pdfua", &pdfua, false).
Custom("metadata", func(value string) error {
if len(value) > 0 {
err := json.Unmarshal([]byte(value), &metadata)
if err != nil {
return fmt.Errorf("unmarshal metadata: %w", err)
}
}
return nil
}).
Validate() Validate()
if err != nil { if err != nil {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
pdfFormats := gotenberg.PdfFormats{
PdfA: pdfa,
PdfUa: pdfua,
}
// Alright, let's merge the PDFs.
outputPath := ctx.GeneratePath(".pdf") outputPath := ctx.GeneratePath(".pdf")
err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath) err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath)
if err != nil { if err != nil {
return fmt.Errorf("merge PDFs: %w", err) return fmt.Errorf("merge PDFs: %w", err)
} }
// So far so good, the PDFs are merged into one unique PDF. outputPaths, err := ConvertStub(ctx, engine, pdfFormats, []string{outputPath})
// Now, let's check if the client want to convert this result PDF
// to specific PDF 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 {
return fmt.Errorf("convert PDF: %w", err)
}
// Important: the output path is now the converted file.
outputPath = convertOutputPath
}
// Writes and potentially overrides metadata entries, if any.
if len(metadata) > 0 {
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
}
// Last but not least, add the output path to the context so that
// the API is able to send it as a response to the client.
err = ctx.AddOutputPaths(outputPath)
if err != nil { if err != nil {
return fmt.Errorf("add output path: %w", err) return fmt.Errorf("convert PDF: %w", err)
}
err = WriteMetadataStub(ctx, engine, metadata, outputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
if flatten {
err = FlattenStub(ctx, engine, outputPaths)
if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
}
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// splitRoute returns an [api.Route] which can extract pages from a PDF.
func splitRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/split",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
mode := FormDataPdfSplitMode(form, true)
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
var inputPaths []string
var flatten bool
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Bool("flatten", &flatten, false).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
outputPaths, err := SplitPdfStub(ctx, engine, mode, inputPaths)
if err != nil {
return fmt.Errorf("split PDFs: %w", err)
}
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
err = WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
if flatten {
err = FlattenStub(ctx, engine, convertOutputPaths)
if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
}
zeroValuedSplitMode := gotenberg.SplitMode{}
zeroValuedPdfFormats := gotenberg.PdfFormats{}
if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats {
// Rename the files 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)
}
}
}
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// flattenRoute returns an [api.Route] which can flatten PDFs.
func flattenRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/flatten",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = FlattenStub(ctx, engine, inputPaths)
if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
} }
return nil return nil
@@ -108,27 +422,17 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
// Let's get the data from the form and validate them. form := ctx.FormData()
var ( pdfFormats := FormDataPdfFormats(form)
inputPaths []string
pdfa string
pdfua bool
)
err := ctx.FormData(). var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths). MandatoryPaths([]string{".pdf"}, &inputPaths).
String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false).
Validate() Validate()
if err != nil { if err != nil {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
pdfFormats := gotenberg.PdfFormats{
PdfA: pdfa,
PdfUa: pdfua,
}
zeroValued := gotenberg.PdfFormats{} zeroValued := gotenberg.PdfFormats{}
if pdfFormats == zeroValued { if pdfFormats == zeroValued {
return api.WrapError( return api.WrapError(
@@ -140,18 +444,14 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
) )
} }
// Alright, let's convert the PDFs. outputPaths, err := ConvertStub(ctx, engine, pdfFormats, inputPaths)
outputPaths := make([]string, len(inputPaths)) if err != nil {
for i, inputPath := range inputPaths { return fmt.Errorf("convert PDFs: %w", err)
outputPaths[i] = ctx.GeneratePath(".pdf") }
err = engine.Convert(ctx, ctx.Log(), pdfFormats, inputPath, outputPaths[i]) if len(outputPaths) > 1 {
if err != nil { // If .zip archive, keep the original filename.
return fmt.Errorf("convert PDF: %w", err) for i, inputPath := range inputPaths {
}
if len(outputPaths) > 1 {
// If .zip archive, keep the original filename.
err = ctx.Rename(outputPaths[i], inputPath) err = ctx.Rename(outputPaths[i], inputPath)
if err != nil { if err != nil {
return fmt.Errorf("rename output path: %w", err) return fmt.Errorf("rename output path: %w", err)
@@ -161,8 +461,6 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
} }
} }
// Last but not least, add the output paths to the context so that
// the API is able to send them as a response to the client.
err = ctx.AddOutputPaths(outputPaths...) err = ctx.AddOutputPaths(outputPaths...)
if err != nil { if err != nil {
return fmt.Errorf("add output paths: %w", err) return fmt.Errorf("add output paths: %w", err)
@@ -182,9 +480,7 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
// Let's get the data from the form and validate them.
var inputPaths []string var inputPaths []string
err := ctx.FormData(). err := ctx.FormData().
MandatoryPaths([]string{".pdf"}, &inputPaths). MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate() Validate()
@@ -192,7 +488,6 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
// Alright, let's read the metadata.
res := make(map[string]map[string]interface{}, len(inputPaths)) res := make(map[string]map[string]interface{}, len(inputPaths))
for _, inputPath := range inputPaths { for _, inputPath := range inputPaths {
metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath) metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath)
@@ -223,41 +518,22 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
// Let's get the data from the form and validate them. form := ctx.FormData()
var ( metadata := FormDataPdfMetadata(form, true)
inputPaths []string
metadata map[string]interface{}
)
err := ctx.FormData(). var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths). MandatoryPaths([]string{".pdf"}, &inputPaths).
MandatoryCustom("metadata", func(value string) error {
if len(value) > 0 {
err := json.Unmarshal([]byte(value), &metadata)
if err != nil {
return fmt.Errorf("unmarshal metadata: %w", err)
}
}
if len(metadata) == 0 {
return errors.New("no metadata")
}
return nil
}).
Validate() Validate()
if err != nil { if err != nil {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
// Alright, let's convert the PDFs. err = WriteMetadataStub(ctx, engine, metadata, inputPaths)
for _, inputPath := range inputPaths { if err != nil {
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, inputPath) return fmt.Errorf("write metadata: %w", err)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
} }
// Last but not least, add the output paths to the context so that
// the API is able to send them as a response to the client.
err = ctx.AddOutputPaths(inputPaths...) err = ctx.AddOutputPaths(inputPaths...)
if err != nil { if err != nil {
return fmt.Errorf("add output paths: %w", err) return fmt.Errorf("add output paths: %w", err)

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
// interface using the PDFtk command-line tool. This package allows for: // interface using the PDFtk command-line tool. This package allows for:
// //
// 1. The merging of PDF files. // 1. The merging of PDF files.
// 2. The splitting of PDF files.
// //
// The path to the PDFtk binary must be specified using the PDFTK_BIN_PATH // The path to the PDFtk binary must be specified using the PDFTK_BIN_PATH
// environment variable. // environment variable.

View File

@@ -1,10 +1,14 @@
package pdftk package pdftk
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath"
"syscall"
"go.uber.org/zap" "go.uber.org/zap"
@@ -51,6 +55,57 @@ func (engine *PdfTk) Validate() error {
return nil return nil
} }
// Debug returns additional debug data.
func (engine *PdfTk) Debug() map[string]interface{} {
debug := make(map[string]interface{})
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
}
lines := bytes.SplitN(output, []byte("\n"), 2)
if len(lines) > 0 {
debug["version"] = string(lines[0])
} else {
debug["version"] = "Unable to determine PDFtk version"
}
return debug
}
// Split splits a given PDF file.
func (engine *PdfTk) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var args []string
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
switch mode.Mode {
case gotenberg.SplitModePages:
if !mode.Unify {
return nil, fmt.Errorf("split PDFs using mode '%s' without unify with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
args = append(args, inputPath, "cat", mode.Span, "output", outputPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("split PDFs with PDFtk: %w", err)
}
return []string{outputPath}, nil
}
// Merge combines multiple PDFs into a single PDF. // Merge combines multiple PDFs into a single PDF.
func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string var args []string
@@ -70,6 +125,11 @@ func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths [
return fmt.Errorf("merge PDFs with PDFtk: %w", err) return fmt.Errorf("merge PDFs with PDFtk: %w", err)
} }
// Flatten is not available in this implementation.
func (engine *PdfTk) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
return fmt.Errorf("flatten PDF with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Convert is not available in this implementation. // Convert is not available in this implementation.
func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
@@ -90,5 +150,6 @@ var (
_ gotenberg.Module = (*PdfTk)(nil) _ gotenberg.Module = (*PdfTk)(nil)
_ gotenberg.Provisioner = (*PdfTk)(nil) _ gotenberg.Provisioner = (*PdfTk)(nil)
_ gotenberg.Validator = (*PdfTk)(nil) _ gotenberg.Validator = (*PdfTk)(nil)
_ gotenberg.Debuggable = (*PdfTk)(nil)
_ gotenberg.PdfEngine = (*PdfTk)(nil) _ gotenberg.PdfEngine = (*PdfTk)(nil)
) )

View File

@@ -71,6 +71,50 @@ func TestPdfTk_Validate(t *testing.T) {
} }
} }
func TestPdfTk_Debug(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *PdfTk
expect map[string]interface{}
doNotExpect map[string]interface{}
}{
{
scenario: "cannot determine version",
engine: &PdfTk{
binPath: "foo",
},
expect: map[string]interface{}{
"version": `exec: "foo": executable file not found in $PATH`,
},
},
{
scenario: "success",
engine: &PdfTk{
binPath: "echo",
},
doNotExpect: map[string]interface{}{
"version": `exec: "echo": executable file not found in $PATH`,
},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
d := tc.engine.Debug()
if tc.expect != nil {
if !reflect.DeepEqual(d, tc.expect) {
t.Errorf("expected '%v' but got '%v'", tc.expect, d)
}
}
if tc.doNotExpect != nil {
if reflect.DeepEqual(d, tc.doNotExpect) {
t.Errorf("did not expect '%v'", d)
}
}
})
}
}
func TestPdfTk_Merge(t *testing.T) { func TestPdfTk_Merge(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
@@ -116,7 +160,7 @@ func TestPdfTk_Merge(t *testing.T) {
t.Fatalf("expected error but got: %v", err) t.Fatalf("expected error but got: %v", err)
} }
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll() outputDir, err := fs.MkdirAll()
if err != nil { if err != nil {
t.Fatalf("expected error but got: %v", err) t.Fatalf("expected error but got: %v", err)
@@ -142,6 +186,105 @@ func TestPdfTk_Merge(t *testing.T) {
} }
} }
func TestPdfCpu_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
mode gotenberg.SplitMode
inputPath string
expectError bool
expectedError error
expectOutputPathsCount int
expectOutputPaths []string
}{
{
scenario: "ErrPdfSplitModeNotSupported",
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "ErrPdfSplitModeNotSupported (no unify with pages)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1", Unify: false},
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "invalid context",
ctx: nil,
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "success (pages & unify)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfTk)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error 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)
}
}()
outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir)
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)
}
if tc.expectOutputPathsCount != len(outputPaths) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths))
}
})
}
}
func TestPdfTk_Flatten(t *testing.T) {
engine := new(PdfTk)
err := engine.Flatten(context.TODO(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestPdfTk_Convert(t *testing.T) { func TestPdfTk_Convert(t *testing.T) {
engine := new(PdfTk) engine := new(PdfTk)
err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")

View File

@@ -2,6 +2,8 @@
// interface using the QPDF command-line tool. This package allows for: // interface using the QPDF command-line tool. This package allows for:
// //
// 1. The merging of PDF files. // 1. The merging of PDF files.
// 2. The splitting of PDF files.
// 3. Flattening of PDF files
// //
// The path to the QPDF binary must be specified using the QPDK_BIN_PATH // The path to the QPDF binary must be specified using the QPDK_BIN_PATH
// environment variable. // environment variable.

View File

@@ -1,10 +1,14 @@
package qpdf package qpdf
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath"
"syscall"
"go.uber.org/zap" "go.uber.org/zap"
@@ -18,7 +22,8 @@ func init() {
// QPdf abstracts the CLI tool QPDF and implements the [gotenberg.PdfEngine] // QPdf abstracts the CLI tool QPDF and implements the [gotenberg.PdfEngine]
// interface. // interface.
type QPdf struct { type QPdf struct {
binPath string binPath string
globalArgs []string
} }
// Descriptor returns a [QPdf]'s module descriptor. // Descriptor returns a [QPdf]'s module descriptor.
@@ -37,6 +42,8 @@ func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
} }
engine.binPath = binPath engine.binPath = binPath
// Warnings should not cause errors.
engine.globalArgs = []string{"--warning-exit-0"}
return nil return nil
} }
@@ -45,16 +52,71 @@ func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
func (engine *QPdf) Validate() error { func (engine *QPdf) Validate() error {
_, err := os.Stat(engine.binPath) _, err := os.Stat(engine.binPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return fmt.Errorf("QPdf binary path does not exist: %w", err) return fmt.Errorf("QPDF binary path does not exist: %w", err)
} }
return nil return nil
} }
// Debug returns additional debug data.
func (engine *QPdf) Debug() map[string]interface{} {
debug := make(map[string]interface{})
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
}
lines := bytes.SplitN(output, []byte("\n"), 2)
if len(lines) > 0 {
debug["version"] = string(lines[0])
} else {
debug["version"] = "Unable to determine QPDF version"
}
return debug
}
// Split splits a given PDF file.
func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var args []string
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
switch mode.Mode {
case gotenberg.SplitModePages:
if !mode.Unify {
return nil, fmt.Errorf("split PDFs using mode '%s' without unify with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
args = append(args, inputPath)
args = append(args, engine.globalArgs...)
args = append(args, "--pages", ".", mode.Span)
args = append(args, "--", outputPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("split PDFs with QPDF: %w", err)
}
return []string{outputPath}, nil
}
// Merge combines multiple PDFs into a single PDF. // Merge combines multiple PDFs into a single PDF.
func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string var args []string
args = append(args, "--empty") args = append(args, "--empty")
args = append(args, engine.globalArgs...)
args = append(args, "--pages") args = append(args, "--pages")
args = append(args, inputPaths...) args = append(args, inputPaths...)
args = append(args, "--", outputPath) args = append(args, "--", outputPath)
@@ -72,6 +134,29 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
return fmt.Errorf("merge PDFs with QPDF: %w", err) return fmt.Errorf("merge PDFs with QPDF: %w", err)
} }
// Flatten merges annotation appearances with page content, deleting the
// original annotations.
func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
var args []string
args = append(args, inputPath)
args = append(args, "--generate-appearances")
args = append(args, "--flatten-annotations=all")
args = append(args, "--replace-input")
args = append(args, engine.globalArgs...)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err == nil {
return nil
}
return fmt.Errorf("flatten PDFs with QPDF: %w", err)
}
// Convert is not available in this implementation. // Convert is not available in this implementation.
func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
@@ -91,5 +176,6 @@ var (
_ gotenberg.Module = (*QPdf)(nil) _ gotenberg.Module = (*QPdf)(nil)
_ gotenberg.Provisioner = (*QPdf)(nil) _ gotenberg.Provisioner = (*QPdf)(nil)
_ gotenberg.Validator = (*QPdf)(nil) _ gotenberg.Validator = (*QPdf)(nil)
_ gotenberg.Debuggable = (*QPdf)(nil)
_ gotenberg.PdfEngine = (*QPdf)(nil) _ gotenberg.PdfEngine = (*QPdf)(nil)
) )

View File

@@ -3,6 +3,8 @@ package qpdf
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io"
"os" "os"
"reflect" "reflect"
"testing" "testing"
@@ -71,6 +73,50 @@ func TestQPdf_Validate(t *testing.T) {
} }
} }
func TestQPdf_Debug(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *QPdf
expect map[string]interface{}
doNotExpect map[string]interface{}
}{
{
scenario: "cannot determine version",
engine: &QPdf{
binPath: "foo",
},
expect: map[string]interface{}{
"version": `exec: "foo": executable file not found in $PATH`,
},
},
{
scenario: "success",
engine: &QPdf{
binPath: "echo",
},
doNotExpect: map[string]interface{}{
"version": `exec: "echo": executable file not found in $PATH`,
},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
d := tc.engine.Debug()
if tc.expect != nil {
if !reflect.DeepEqual(d, tc.expect) {
t.Errorf("expected '%v' but got '%v'", tc.expect, d)
}
}
if tc.doNotExpect != nil {
if reflect.DeepEqual(d, tc.doNotExpect) {
t.Errorf("did not expect '%v'", d)
}
}
})
}
}
func TestQPdf_Merge(t *testing.T) { func TestQPdf_Merge(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
@@ -108,6 +154,15 @@ func TestQPdf_Merge(t *testing.T) {
}, },
expectError: false, expectError: false,
}, },
{
scenario: "success even with warnings",
ctx: context.TODO(),
inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample5.pdf",
},
expectError: false,
},
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
engine := new(QPdf) engine := new(QPdf)
@@ -116,7 +171,7 @@ func TestQPdf_Merge(t *testing.T) {
t.Fatalf("expected error but got: %v", err) t.Fatalf("expected error but got: %v", err)
} }
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll() outputDir, err := fs.MkdirAll()
if err != nil { if err != nil {
t.Fatalf("expected error but got: %v", err) t.Fatalf("expected error but got: %v", err)
@@ -142,6 +197,206 @@ func TestQPdf_Merge(t *testing.T) {
} }
} }
func TestQPdf_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
mode gotenberg.SplitMode
inputPath string
expectError bool
expectedError error
expectOutputPathsCount int
expectOutputPaths []string
}{
{
scenario: "ErrPdfSplitModeNotSupported",
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "ErrPdfSplitModeNotSupported (no unify with pages)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1", Unify: false},
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "invalid context",
ctx: nil,
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "success (pages & unify)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
{
scenario: "success even with warnings",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample5.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(QPdf)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error 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)
}
}()
outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir)
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)
}
if tc.expectOutputPathsCount != len(outputPaths) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths))
}
})
}
}
func TestQPdf_Flatten(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
inputPath string
createCopy bool
expectError bool
}{
{
scenario: "invalid context",
ctx: nil,
expectError: true,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
inputPath: "foo.pdf",
expectError: true,
},
{
scenario: "success",
ctx: context.TODO(),
inputPath: "/tests/test/testdata/pdfengines/sample3.pdf",
createCopy: true,
expectError: false,
},
{
scenario: "success even with warnings",
ctx: context.TODO(),
inputPath: "/tests/test/testdata/pdfengines/sample5.pdf",
createCopy: true,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(QPdf)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
var destinationPath string
if tc.createCopy {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error no 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)
}
}()
destinationPath = fmt.Sprintf("%s/copy_temp.pdf", outputDir)
source, err := os.Open(tc.inputPath)
if err != nil {
t.Fatalf("open source file: %v", err)
}
defer func(source *os.File) {
err := source.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}(source)
destination, err := os.Create(destinationPath)
if err != nil {
t.Fatalf("create destination file: %v", err)
}
defer func(destination *os.File) {
err := destination.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}(destination)
_, err = io.Copy(destination, source)
if err != nil {
t.Fatalf("copy source into destination: %v", err)
}
} else {
destinationPath = tc.inputPath
}
err = engine.Flatten(tc.ctx, zap.NewNop(), destinationPath)
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 TestQPdf_Convert(t *testing.T) { func TestQPdf_Convert(t *testing.T) {
engine := new(QPdf) engine := new(QPdf)
err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")

View File

@@ -1,84 +0,0 @@
#!/bin/bash
set -e
# Args.
GOLANG_VERSION="$1"
GOTENBERG_VERSION="$2"
GOTENBERG_USER_GID="$3"
GOTENBERG_USER_UID="$4"
NOTO_COLOR_EMOJI_VERSION="$5"
PDFTK_VERSION="$6"
PDFCPU_VERSION="$7"
DOCKER_REGISTRY="$8"
DOCKER_REPOSITORY="$9"
LINUX_AMD64_RELEASE="${10}"
# Find out if given version is "semver".
GOTENBERG_VERSION="${GOTENBERG_VERSION//v}"
IFS='.' read -ra SEMVER <<< "$GOTENBERG_VERSION"
VERSION_LENGTH=${#SEMVER[@]}
TAGS=()
TAGS_CLOUD_RUN=()
if [ "$VERSION_LENGTH" -eq 3 ]; then
MAJOR="${SEMVER[0]}"
MINOR="${SEMVER[1]}"
PATCH="${SEMVER[2]}"
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest")
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR")
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR")
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR.$PATCH")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR-cloudrun")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR-cloudrun")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR.$PATCH-cloudrun")
else
# Normalizes version.
GOTENBERG_VERSION="${GOTENBERG_VERSION// /-}"
GOTENBERG_VERSION="$(echo "$GOTENBERG_VERSION" | tr -cd '[:alnum:]._\-')"
if [[ "$GOTENBERG_VERSION" =~ ^[\.\-] ]]; then
GOTENBERG_VERSION="_${GOTENBERG_VERSION#?}"
fi
if [ "${#GOTENBERG_VERSION}" -gt 128 ]; then
GOTENBERG_VERSION="${GOTENBERG_VERSION:0:128}"
fi
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION-cloudrun")
fi
# Multi-arch build takes a lot of time.
if [ "$LINUX_AMD64_RELEASE" = true ]; then
PLATFORM_FLAG="--platform linux/amd64"
else
PLATFORM_FLAG="--platform linux/amd64,linux/arm64,linux/386,linux/arm/v7"
fi
docker buildx 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" \
--build-arg PDFCPU_VERSION="$PDFCPU_VERSION" \
$PLATFORM_FLAG \
"${TAGS[@]}" \
--push \
-f build/Dockerfile .
# Cloud Run variant.
# Only linux/amd64! See https://github.com/gotenberg/gotenberg/issues/505#issuecomment-1264679278.
docker buildx build \
--build-arg DOCKER_REGISTRY="$DOCKER_REGISTRY" \
--build-arg DOCKER_REPOSITORY="$DOCKER_REPOSITORY" \
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
--platform linux/amd64 \
"${TAGS_CLOUD_RUN[@]}" \
--push \
-f build/Dockerfile.cloudrun .

View File

@@ -19,6 +19,7 @@ ENV CGO_ENABLED=1
COPY --from=golang /usr/local/go /usr/local/go COPY --from=golang /usr/local/go /usr/local/go
RUN apt-get update -qq &&\ RUN apt-get update -qq &&\
apt-get upgrade -yqq &&\
apt-get install -y -qq --no-install-recommends \ apt-get install -y -qq --no-install-recommends \
sudo \ sudo \
# gcc for cgo. # gcc for cgo.

BIN
test/testdata/pdfengines/sample3.pdf vendored Normal file

Binary file not shown.

BIN
test/testdata/pdfengines/sample4.pdf vendored Normal file

Binary file not shown.

BIN
test/testdata/pdfengines/sample5.pdf vendored Normal file

Binary file not shown.