diff --git a/.dockerignore b/.dockerignore index 191381ee7..6b8710a71 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1 @@ -.git \ No newline at end of file +.git diff --git a/.env b/.env new file mode 100644 index 000000000..db1010749 --- /dev/null +++ b/.env @@ -0,0 +1,7 @@ +GOTENBERG_VERSION=snapshot +DOCKER_REGISTRY=ghcr.io/fulll +DOCKER_REPOSITORY=gotenberg +DOCKERFILE=build/Dockerfile +DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun +DOCKERFILE_AWS_LAMBDA=build/Dockerfile.aws-lambda +DOCKER_BUILD_CONTEXT='.' diff --git a/.github/actions/build-test-push/action.yml b/.github/actions/build-test-push/action.yml new file mode 100644 index 000000000..430f81b21 --- /dev/null +++ b/.github/actions/build-test-push/action.yml @@ -0,0 +1,93 @@ +name: Build Test Push +description: Build, test and push Docker images for a given platform +author: Julien Neuhart + +inputs: + github_token: + description: The GitHub token + required: true + default: ${{ github.token }} + # docker_hub_username: + # description: The Docker Hub username + # required: true + # docker_hub_password: + # description: The Docker Hub password + # required: true + platform: + description: linux/amd64, linux/ppc64le, linux/386, linux/arm64, linux/arm/v7 + required: true + version: + description: Gotenberg version + required: true + skip_integrations_tests: + description: Define whether to skip integration testing + default: false + alternate_repository: + description: Alternate repository to push the tags to + dry_run: + description: Dry run this action + +outputs: + tags: + description: Comma separated list of tag + value: ${{ steps.build.outputs.tags }} + tags_cloud_run: + description: Comma separated list of Cloud Run tags (linux/amd64 only) + value: ${{ steps.build.outputs.tags_cloud_run }} + tags_aws_lambda: + description: Comma separated list of AWS Lambda tags (linux/amd64 and linux/arm64 only) + value: ${{ steps.build.outputs.tags_aws_lambda }} + +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@v5 + + # - name: Log in to Docker Hub + # if: inputs.docker_hub_username != '' + # uses: docker/login-action@v3 + # with: + # username: ${{ inputs.docker_hub_username }} + # password: ${{ inputs.docker_hub_password }} + + - name: Build ${{ inputs.platform }} + id: build + shell: bash + run: | + .github/actions/build-test-push/build.sh \ + --version "${{ inputs.version }}" \ + --platform "${{ inputs.platform }}" \ + --alternate-repository "${{ inputs.alternate_repository }}" \ + --dry-run "${{ inputs.dry_run }}" + + - name: Run integration tests + if: inputs.skip_integrations_tests != 'true' + shell: bash + run: | + .github/actions/build-test-push/test.sh \ + --version "${{ inputs.version }}" \ + --platform "${{ inputs.platform }}" \ + --alternate-repository "${{ inputs.alternate_repository }}" \ + --dry-run "${{ inputs.dry_run }}" + + # - name: Push + # if: inputs.docker_hub_username != '' + # shell: bash + # run: | + # .github/actions/build-test-push/push.sh \ + # --tags "${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_aws_lambda }}" \ + # --dry-run "${{ inputs.dry_run }}" + + - name: Outputs + shell: bash + run: | + echo "tags=${{ steps.build.outputs.tags }}" + echo "tags_cloud_run=${{ steps.build.outputs.tags_cloud_run }}" + echo "tags_aws_lambda=${{ steps.build.outputs.tags_aws_lambda }}" diff --git a/.github/actions/build-test-push/build.sh b/.github/actions/build-test-push/build.sh new file mode 100755 index 000000000..dd5da2c4c --- /dev/null +++ b/.github/actions/build-test-push/build.sh @@ -0,0 +1,184 @@ +#!/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=() +tags_aws_lambda=() + +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 + + if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then + tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-aws-lambda-${arch[1]}") + tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-aws-lambda-${arch[1]}") + tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-aws-lambda-${arch[1]}") + tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-aws-lambda-${arch[1]}") + 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 + + if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then + tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-aws-lambda-${arch[1]}") + fi +fi + +tags_flags=() +tags_cloud_run_flags=() +tags_aws_lambda_flags=() + +echo "Will use the following tags:" +for tag in "${tags[@]}"; do + tags_flags+=("-t" "$tag") + echo "- $tag" +done +for tag in "${tags_cloud_run[@]}"; do + tags_cloud_run_flags+=("-t" "$tag") + echo "- $tag" +done +for tag in "${tags_aws_lambda[@]}"; do + tags_aws_lambda_flags+=("-t" "$tag") + echo "- $tag" +done +echo + +# Build images. +run_cmd() { + local cmd="$1" + + if [ "$dry_run" = "true" ]; then + echo "๐Ÿšง Dry run - would run the following command:" + echo "$cmd" + echo + else + echo "โš™๏ธ Running command:" + echo "$cmd" + eval "$cmd" + echo + fi +} + +join() { + local delimiter="$1" + shift + local IFS="$delimiter" + echo "$*" +} + +no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version" + +cmd="docker buildx build \ + --build-arg GOTENBERG_VERSION=$version \ + --platform $platform \ + --load \ + ${tags_flags[*]} \ + -t $no_arch_tag \ + -f $DOCKERFILE $DOCKER_BUILD_CONTEXT +" +run_cmd "$cmd" + +if [ "$platform" = "linux/amd64" ]; then + cmd="docker build \ + --build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \ + --build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \ + --build-arg GOTENBERG_VERSION=$version \ + ${tags_cloud_run_flags[*]} \ + -f $DOCKERFILE_CLOUDRUN $DOCKER_BUILD_CONTEXT + " + run_cmd "$cmd" +fi + +if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then + cmd="docker build \ + --build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \ + --build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \ + --build-arg GOTENBERG_VERSION=$version \ + ${tags_aws_lambda_flags[*]} \ + -f $DOCKERFILE_AWS_LAMBDA $DOCKER_BUILD_CONTEXT + " + run_cmd "$cmd" +fi + +echo "โœ… Done!" +echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT" +echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT" +echo "tags_aws_lambda=$(join "," "${tags_aws_lambda[@]}")" >> "$GITHUB_OUTPUT" +exit 0 diff --git a/.github/actions/build-test-push/push.sh b/.github/actions/build-test-push/push.sh new file mode 100755 index 000000000..a86d27a03 --- /dev/null +++ b/.github/actions/build-test-push/push.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# Exit early. +# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin. +set -e + +# Source dot env file. +source .env + +# Arguments. +tags="" +dry_run="" + +while [[ $# -gt 0 ]]; do + case $1 in + --tags) + tags="$2" + shift 2 + ;; + --dry-run) + dry_run=$2 + shift 2 + ;; + *) + echo "Unknown option $1" + exit 1 + ;; + esac +done + +echo "Push tag(s) ๐Ÿ“ฆ" +echo + +echo "Tag(s) to push:" +IFS=',' read -ra tmp_tags_to_push <<< "$tags" + +tags_to_push=() +for tag in "${tmp_tags_to_push[@]}"; do + [ -n "$tag" ] && tags_to_push+=("$tag") +done + +for tag in "${tags_to_push[@]}"; do + echo "- $tag" +done + +if [ "$dry_run" = "true" ]; then + echo "๐Ÿšง Dry run" +fi +echo + +# Push tags. +run_cmd() { + local cmd="$1" + + if [ "$dry_run" = "true" ]; then + echo "๐Ÿšง Dry run - would run the following command:" + echo "$cmd" + echo + else + echo "โš™๏ธ Running command:" + echo "$cmd" + eval "$cmd" + echo + fi +} + +for tag in "${tags_to_push[@]}"; do + cmd="docker push $tag" + run_cmd "$cmd" + + echo "โžก๏ธ $tag pushed" + echo +done + +echo "โœ… Done!" +exit 0 diff --git a/.github/actions/build-test-push/test.sh b/.github/actions/build-test-push/test.sh new file mode 100755 index 000000000..cc44776bc --- /dev/null +++ b/.github/actions/build-test-push/test.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# Exit early. +# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin. +set -e + +# Source dot env file. +source .env + +# Arguments. +version="" +platform="" +alternate_repository="" +dry_run="" + +while [[ $# -gt 0 ]]; do + case $1 in + --version) + version="${2//v/}" + shift 2 + ;; + --platform) + platform="$2" + shift 2 + ;; + --alternate-repository) + alternate_repository="$2" + shift 2 + ;; + --dry-run) + dry_run="$2" + shift 2 + ;; + *) + echo "Unknown option $1" + exit 1 + ;; + esac +done + +echo "Integration testing ๐Ÿงช" +echo + +echo "Gotenberg version: $version" +echo "Target platform: $platform" + +repository=$DOCKER_REPOSITORY + +if [ -n "$alternate_repository" ]; then + echo "โš ๏ธ Using $alternate_repository for DOCKER_REPOSITORY" + repository=$alternate_repository +fi + +if [ "$dry_run" = "true" ]; then + echo "๐Ÿšง Dry run" +fi +echo + +# Test image. +run_cmd() { + local cmd="$1" + + if [ "$dry_run" = "true" ]; then + echo "๐Ÿšง Dry run - would run the following command:" + echo "$cmd" + echo + else + echo "โš™๏ธ Running command:" + echo "$cmd" + eval "$cmd" + echo + fi +} + +cmd="make test-integration DOCKER_REPOSITORY=$repository GOTENBERG_VERSION=$version PLATFORM=$platform NO_CONCURRENCY=true" +run_cmd "$cmd" + +echo "โœ… Done!" +exit 0 diff --git a/.github/actions/clean/action.yml b/.github/actions/clean/action.yml new file mode 100644 index 000000000..4dae20bf4 --- /dev/null +++ b/.github/actions/clean/action.yml @@ -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 }}" diff --git a/.github/actions/clean/clean.sh b/.github/actions/clean/clean.sh new file mode 100755 index 000000000..14a5429ee --- /dev/null +++ b/.github/actions/clean/clean.sh @@ -0,0 +1,123 @@ +#!/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") + tags_to_delete+=("$DOCKER_REGISTRY/snapshot:$snapshot_version-aws-lambda") +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 diff --git a/.github/actions/merge/action.yml b/.github/actions/merge/action.yml new file mode 100644 index 000000000..002146793 --- /dev/null +++ b/.github/actions/merge/action.yml @@ -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@v5 + + - 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 }}" diff --git a/.github/actions/merge/merge.sh b/.github/actions/merge/merge.sh new file mode 100755 index 000000000..ce405fbed --- /dev/null +++ b/.github/actions/merge/merge.sh @@ -0,0 +1,108 @@ +#!/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//-ppc64le/}" + 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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2b9f8833a..544562557 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,18 @@ version: 2 updates: - - # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + - package-ecosystem: "docker" + directory: "/build" + schedule: + interval: "weekly" + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/stale.yml b/.github/stale.yml index 8cdadff12..fa0398517 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -15,4 +15,4 @@ markComment: > recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false \ No newline at end of file +closeComment: false diff --git a/.github/workflows/continuous-delivery.yml b/.github/workflows/continuous-delivery.yml new file mode 100644 index 000000000..65f6d5a3c --- /dev/null +++ b/.github/workflows/continuous-delivery.yml @@ -0,0 +1,58 @@ +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 }} + tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }} + steps: + - name: Checkout source code + uses: actions/checkout@v6 + + # action modified to onlu build + - name: Build and push + id: build_push + uses: ./.github/actions/build-test-push + with: + version: ${{ github.event.release.tag_name }} + platform: linux/amd64 + skip_integrations_tests: true + + # list docker images that have bee built + - name: Output built tags to console + run: | + echo "Tags: ${{ steps.build_push.outputs.tags }}" + echo "Cloud Run Tags: ${{ steps.build_push.outputs.tags_cloud_run }}" + echo "AWS Lambda Tags: ${{ steps.build_push.outputs.tags_aws_lambda }}" + + - name: generate aws credentials config + env: + AWS_CREDENTIALS: ${{ secrets.STAGING_AWS_CREDENTIALS }} + aws-region: eu-central-1 + run: | + mkdir -p "${HOME}/.aws" + echo "${AWS_CREDENTIALS}" > "${HOME}/.aws/credentials" + + # Get the image build by the upstream process then : + # - tag it for AWS ECR + # - push it to AWS ECR + - name: docker login and push + run: | + # Extract the tag name and strip the first letter using cut + TAG_NAME=$(echo "${{ github.event.release.tag_name }}" | cut -c 2-) + + docker tag ghcr.io/fulll/gotenberg:latest-cloudrun 285715278780.dkr.ecr.eu-central-1.amazonaws.com/gotenberg-fulll:${TAG_NAME}-cloudrun + aws --region eu-central-1 ecr get-login-password | docker login --username AWS --password-stdin 285715278780.dkr.ecr.eu-central-1.amazonaws.com + docker tag 285715278780.dkr.ecr.eu-central-1.amazonaws.com/gotenberg-fulll:${TAG_NAME}-cloudrun 285715278780.dkr.ecr.eu-central-1.amazonaws.com/gotenberg-fulll:latest + docker push 285715278780.dkr.ecr.eu-central-1.amazonaws.com/gotenberg-fulll:${TAG_NAME}-cloudrun + docker push 285715278780.dkr.ecr.eu-central-1.amazonaws.com/gotenberg-fulll:latest diff --git a/.github/workflows/continuous_delivery.yml b/.github/workflows/continuous_delivery.yml deleted file mode 100644 index b6b5e70d0..000000000 --- a/.github/workflows/continuous_delivery.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml deleted file mode 100644 index 35b5df5ad..000000000 --- a/.github/workflows/continuous_integration.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/pull-request-cleanup.yml b/.github/workflows/pull-request-cleanup.yml new file mode 100644 index 000000000..47785a13f --- /dev/null +++ b/.github/workflows/pull-request-cleanup.yml @@ -0,0 +1,24 @@ +name: Pull Request Cleanup + +on: + pull_request: + types: [closed] + +permissions: + contents: read + +jobs: + cleanup: + name: Cleanup Docker images + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Check out code + uses: actions/checkout@v6 + + - 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 }} diff --git a/.gitignore b/.gitignore index 8c9b840c2..6ae82b8ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ -/coverage.html -/coverage.txt .idea .vscode .DS_Store +/node_modules/ diff --git a/.golangci.yml b/.golangci.yml index d4f38a3a2..1108eb25d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,60 +1,65 @@ -linters-settings: - gci: - sections: - - standard - - default - - prefix(github.com/gotenberg/gotenberg/v8) - skip-generated: true - custom-order: true - # Until https://github.com/securego/gosec/issues/1187 is resolved. - gosec: - excludes: - - G115 - +version: "2" +run: + issues-exit-code: 1 + tests: false linters: - disable-all: true + default: none enable: - asasalint - asciicheck - bidichk - bodyclose + - copyloopvar - decorder - dogsled - dupl - dupword - durationcheck - - copyloopvar - errcheck - errname - exhaustive - - gci - - gofmt - - goimports - - gofumpt - gosec - - gosimple - govet - - ineffassign - importas + - ineffassign - misspell - prealloc - promlinter - #- sloglint - staticcheck - - tenv - testableexamples - tparallel - - typecheck - unconvert - unused + - usetesting - wastedassign - whitespace - -run: - timeout: 5m - issues-exit-code: 1 - tests: false - -output: - print-issued-lines: true - print-linter-name: true + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + settings: + gci: + sections: + - standard + - default + - prefix(github.com/gotenberg/gotenberg/v8) + custom-order: true + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..0a492611a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.11.0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..378eac25d --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +build diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..18106badc --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "plugins": ["prettier-plugin-gherkin", "prettier-plugin-sh"], + "escapeBackslashes": true +} diff --git a/FEATURE_SPECIFICATION.md b/FEATURE_SPECIFICATION.md new file mode 100644 index 000000000..bc6f04a9d --- /dev/null +++ b/FEATURE_SPECIFICATION.md @@ -0,0 +1,765 @@ +# Feature Implementation Specification: PDF Bookmarks Import + +## Overview +This document specifies the changes implemented to add PDF bookmark import functionality to Gotenberg using the pdfcpu library. The feature allows users to provide bookmark data when converting HTML/Markdown to PDF via the Chromium module, which are then imported into the generated PDF using pdfcpu. + +## Core Feature: PDF Bookmarks Import + +### 1. PDF Engine Interface Extension + +**File**: `pkg/gotenberg/pdfengine.go` + +**Change**: Add a new method to the `PdfEngine` interface: + +```go +// ImportBookmarks imports bookmarks from a JSON file into a given PDF. +ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error +``` + +**Parameters**: +- `inputPath`: Path to the source PDF file +- `inputBookmarksPath`: Path to the JSON file containing bookmark data (in pdfcpu format) +- `outputPath`: Path where the PDF with imported bookmarks will be saved + +--- + +### 2. PdfCpu Implementation + +**File**: `pkg/modules/pdfcpu/pdfcpu.go` + +**Changes**: + +1. **Update module documentation** (`doc.go`): + - Add "2. Import bookmarks in a PDF file." to the list of capabilities + +2. **Implement `ImportBookmarks` method**: + +```go +func (engine *PdfCpu) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error { + if inputBookmarksPath == "" { + return nil + } + + var args []string + args = append(args, "bookmarks", "import", inputPath, inputBookmarksPath, outputPath) + + 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("ImportBookmarks PDFs with pdfcpu: %w", err) +} +``` + +**Logic**: +- If no bookmarks path provided, return nil (no-op) +- Execute pdfcpu command: `pdfcpu bookmarks import ` +- Handle errors appropriately + +--- + +### 3. Stub Implementations for Other PDF Engines + +Add `ImportBookmarks` methods returning `gotenberg.ErrPdfEngineMethodNotSupported` error to: + +**Files**: +- `pkg/modules/exiftool/exiftool.go` +- `pkg/modules/libreoffice/pdfengine/pdfengine.go` +- `pkg/modules/pdftk/pdftk.go` +- `pkg/modules/qpdf/qpdf.go` + +**Implementation** (same for all): + +```go +func (engine *[EngineName]) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error { + return fmt.Errorf("import bookmarks into PDF with [EngineName]: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} +``` + +--- + +### 4. Mock Update + +**File**: `pkg/gotenberg/mocks.go` + +**Changes**: + +1. Add `ImportBookmarksMock` field to `PdfEngineMock` struct: + +```go +type PdfEngineMock struct { + MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error + ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error + 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 + ImportBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error +} +``` + +2. Implement the mock method: + +```go +func (engine *PdfEngineMock) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error { + return engine.ImportBookmarksMock(ctx, logger, inputPath, inputBookmarksPath, outputPath) +} +``` + +--- + +### 5. Multi PDF Engines Support + +**File**: `pkg/modules/pdfengines/multi.go` + +**Changes**: + +1. Add `importBookmarksEngines` field to `multiPdfEngines` struct: + +```go +type multiPdfEngines struct { + mergeEngines []gotenberg.PdfEngine + convertEngines []gotenberg.PdfEngine + readMedataEngines []gotenberg.PdfEngine + writeMedataEngines []gotenberg.PdfEngine + importBookmarksEngines []gotenberg.PdfEngine +} +``` + +2. Update constructor `newMultiPdfEngines` to accept the new parameter + +3. Implement `ImportBookmarks` method with concurrent engine execution pattern (similar to other methods): + +```go +func (multi *multiPdfEngines) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error { + var err error + errChan := make(chan error, 1) + + for _, engine := range multi.importBookmarksEngines { + go func(engine gotenberg.PdfEngine) { + errChan <- engine.ImportBookmarks(ctx, logger, inputPath, inputBookmarksPath, outputPath) + }(engine) + + select { + case mergeErr := <-errChan: + errored := multierr.AppendInto(&err, mergeErr) + if !errored { + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + } + + return fmt.Errorf("import bookmarks into PDF with multi PDF engines: %w", err) +} +``` + +**Note**: The logic tries engines in order until one succeeds or all fail. + +--- + +### 6. PDF Engines Module Configuration + +**File**: `pkg/modules/pdfengines/pdfengines.go` + +**Changes**: + +1. Add `importBookmarksNames` field to `PdfEngines` struct: + +```go +type PdfEngines struct { + mergeNames []string + convertNames []string + readMetadataNames []string + writeMedataNames []string + importBookmarksNames []string + engines []gotenberg.PdfEngine + disableRoutes bool +} +``` + +2. Add flag in `Descriptor()` method: + +```go +fs.StringSlice("pdfengines-import-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the import bookmarks feature - empty means all") +``` + +**Default**: `["pdfcpu"]` + +3. Update `Provision()` to read and assign the flag: + +```go +importBookmarksNames := flags.MustStringSlice("pdfengines-import-bookmarks-engines") +// ... later in the method +mod.importBookmarksNames = defaultNames +if len(importBookmarksNames) > 0 { + mod.importBookmarksNames = importBookmarksNames +} +``` + +4. Add validation in `Validate()`: + +```go +findNonExistingEngines(mod.importBookmarksNames) +``` + +5. Add system message in `SystemMessages()`: + +```go +fmt.Sprintf("import bookmarks engines - %s", strings.Join(mod.importBookmarksNames[:], " ")) +``` + +6. Update `PdfEngine()` method to pass import bookmarks engines to constructor: + +```go +return newMultiPdfEngines( + engines(mod.mergeNames), + engines(mod.convertNames), + engines(mod.readMetadataNames), + engines(mod.writeMedataNames), + engines(mod.importBookmarksNames), +), nil +``` + +--- + +### 7. PDF Engines Routes Helper + +**File**: `pkg/modules/pdfengines/routes.go` + +**Add**: New stub function `ImportBookmarksStub`: + +```go +func ImportBookmarksStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPath string, inputBookmarks []byte, outputPath string) (string, error) { + if len(inputBookmarks) == 0 { + fmt.Println("ImportBookmarksStub BM empty") + return inputPath, nil + } + + inputBookmarksPath := ctx.GeneratePath(".json") + err := os.WriteFile(inputBookmarksPath, inputBookmarks, 0o600) + if err != nil { + return "", fmt.Errorf("write file %v: %w", inputBookmarksPath, err) + } + err = engine.ImportBookmarks(ctx, ctx.Log(), inputPath, inputBookmarksPath, outputPath) + if err != nil { + return "", fmt.Errorf("import bookmarks %v: %w", inputPath, err) + } + + return outputPath, nil +} +``` + +**Logic**: +- Takes bookmark data as JSON bytes +- If empty, returns input path unchanged +- Creates temporary JSON file with bookmark data +- Calls engine's ImportBookmarks method +- Returns output path on success + +**Note**: Need to import "os" package. + +--- + +### 8. Chromium Module Integration + +**File**: `pkg/modules/chromium/chromium.go` + +**Changes**: + +1. Import pdfcpu package: + ```go + import "github.com/pdfcpu/pdfcpu/pkg/pdfcpu" + ``` + +2. Add `Bookmarks` field to `PdfOptions` struct: + +```go +type PdfOptions struct { + // ... existing fields ... + + // Bookmarks to be inserted unmarshaled + // as defined in pdfcpu bookmarks export + Bookmarks pdfcpu.BookmarkTree + + // ... remaining fields ... +} +``` + +3. Update `DefaultPdfOptions()` to initialize bookmarks: + +```go +func DefaultPdfOptions() PdfOptions { + return PdfOptions{ + // ... existing fields ... + Bookmarks: pdfcpu.BookmarkTree{}, + // ... remaining fields ... + } +} +``` + +--- + +**File**: `pkg/modules/chromium/routes.go` + +**Changes**: + +1. Import required packages: + ```go + import "github.com/pdfcpu/pdfcpu/pkg/pdfcpu" + ``` + +2. In `FormDataChromiumPdfOptions` function, add bookmark parsing: + + a. Add variable declaration: + ```go + var ( + // ... existing variables ... + bookmarks pdfcpu.BookmarkTree + ) + ``` + + b. Add custom form field handler: + ```go + Custom("bookmarks", func(value string) error { + if len(value) > 0 { + err := json.Unmarshal([]byte(value), &bookmarks) + if err != nil { + return fmt.Errorf("unmarshal bookmarks: %w", err) + } + } else { + bookmarks = defaultPdfOptions.Bookmarks + } + return nil + }) + ``` + + c. Include in returned options: + ```go + return formData, PdfOptions{ + // ... existing fields ... + Bookmarks: bookmarks, + // ... remaining fields ... + } + ``` + +3. In `convertUrl` function (after PDF generation, before conversion), add bookmark import logic: + +```go +if options.GenerateDocumentOutline { + if len(options.Bookmarks.Bookmarks) > 0 { + bookmarks, errMarshal := json.Marshal(options.Bookmarks) + outputBMPath := ctx.GeneratePath(".pdf") + + if errMarshal == nil { + outputPath, err = pdfengines.ImportBookmarksStub(ctx, engine, outputPath, bookmarks, outputBMPath) + if err != nil { + return fmt.Errorf("import bookmarks into PDF err: %w", err) + } + } else { + return fmt.Errorf("import bookmarks into PDF errMarshal : %w", errMarshal) + } + } +} +``` + +**Logic**: +- Only process bookmarks if `GenerateDocumentOutline` is true and bookmarks exist +- Marshal the bookmarks back to JSON +- Generate output path for PDF with bookmarks +- Call `ImportBookmarksStub` helper +- Update `outputPath` to the new path with bookmarks +- This happens **before** the `pdfengines.ConvertStub` call + +--- + +### 9. Test Updates + +**File**: `pkg/modules/pdfengines/multi_test.go` + +**Changes**: Add `nil` parameter to all `newMultiPdfEngines` calls in tests (for import bookmarks engines). + +Example: +```go +newMultiPdfEngines( + // ... existing parameters ... + nil, // import bookmarks engines +) +``` + +**Locations**: All test cases in `TestMultiPdfEngines_*` functions. + +--- + +**File**: `pkg/modules/pdfengines/pdfengines_test.go` + +**Changes**: + +1. Add `importBookmarksNames` field initialization in test structs: + +```go +mod := PdfEngines{ + mergeNames: []string{"foo", "bar"}, + convertNames: []string{"foo", "bar"}, + readMetadataNames: []string{"foo", "bar"}, + writeMedataNames: []string{"foo", "bar"}, + importBookmarksNames: []string{"foo", "bar"}, + engines: // ... +} +``` + +2. Update expected message count in `TestPdfEngines_SystemMessages`: + - Change from `4` to `5` messages + +3. Add expected message for import bookmarks: + +```go +expectedMessages := []string{ + // ... existing messages ... + fmt.Sprintf("import bookmarks engines - %s", strings.Join(mod.importBookmarksNames[:], " ")), +} +``` + +**Note**: Some test cases may have commented out assertions for `expectedImportBookmarksPdfEngines` - these should be implemented or left as TODOs based on project conventions. + +--- + +## Dependencies + +### Go Module Updates + +**File**: `go.mod` + +**Changes**: + +1. Add pdfcpu dependency in require block: + +```go +require ( + github.com/dlclark/regexp2 v1.11.4 + github.com/pdfcpu/pdfcpu v0.9.1 +) +``` + +2. Add indirect dependencies: + +```go +require ( + // ... existing ... + github.com/hhrutter/lzw v1.0.0 // indirect + github.com/hhrutter/tiff v1.0.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + golang.org/x/image v0.21.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) +``` + +**File**: `go.sum` + +Updated with checksums for all new dependencies and their transitive dependencies. + +--- + +## Build and Deployment Changes + +### 1. Dockerfile + +**File**: `build/Dockerfile` + +**Changes**: Add support for pinning Chrome version via build argument: + +1. Add build argument: + ```dockerfile + ARG CHROME_VERSION + ``` + +2. Modify Chrome installation logic (line ~152) to support conditional installation: + +```dockerfile +RUN \ + /bin/bash -c \ + 'set -e &&\ + if [[ "$(dpkg --print-architecture)" == "amd64" ]]; then \ + apt-get update -qq &&\ + if [ -z "$CHROME_VERSION" ]; then \ + # Install latest stable version + curl https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\ + echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\ + apt-get update -qq &&\ + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\ + mv /usr/bin/google-chrome-stable /usr/bin/chromium; \ + else \ + # Install specific version + apt-get update -qq &&\ + curl --output /tmp/chrome.deb "https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb" &&\ + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends /tmp/chrome.deb &&\ + mv /usr/bin/google-chrome-stable /usr/bin/chromium &&\ + rm -rf /tmp/chrome.deb; \ + fi \ + elif [[ "$(dpkg --print-architecture)" == "armhf" ]]; then \ + # ... existing armhf logic unchanged ... +``` + +**Logic**: +- If `CHROME_VERSION` is empty/unset: install latest stable version (original behavior) +- If `CHROME_VERSION` is set: download and install specific .deb file from Google's repository + +--- + +### 2. Makefile + +**File**: `Makefile` + +**Changes**: + +1. Update default Docker registry: + ```makefile + DOCKER_REGISTRY=ghcr.io/fulll + ``` + (was: `DOCKER_REGISTRY=gotenberg`) + +2. Add `CHROME_VERSION` build argument to `build` target: + +```makefile +build: + # ... existing arguments ... + --build-arg CHROME_VERSION=$(CHROME_VERSION) \ + # ... rest of command ... +``` + +3. Add `CHROME_VERSION` to `build-tests` target: + +```makefile +build-tests: + # ... existing arguments ... + --build-arg CHROME_VERSION=$(CHROME_VERSION) \ + # ... rest of command ... +``` + +4. Add `CHROME_VERSION` parameter to `release` target: + +```makefile +release: + $(PDFCPU_VERSION) \ + $(DOCKER_REGISTRY) \ + $(DOCKER_REPOSITORY) \ + $(LINUX_AMD64_RELEASE) \ + $(CHROME_VERSION) # Add as 11th parameter +``` + +--- + +### 3. Release Script + +**File**: `scripts/release.sh` + +**Changes**: + +1. Add `CHROME_VERSION` parameter (11th argument): + ```bash + CHROME_VERSION="${11}" + ``` + +2. Remove multi-arch platform flag logic, force Linux AMD64 only: + ```bash + # Replace conditional logic with: + PLATFORM_FLAG="--platform linux/amd64" + ``` + (Note: Original had conditional for AMD64 only vs multi-arch) + +3. Add `CHROME_VERSION` build argument to docker buildx command: + +```bash +docker buildx build \ + # ... existing arguments ... + --build-arg CHROME_VERSION="$CHROME_VERSION" \ + # ... rest of command ... +``` + +--- + +### 4. GitHub Actions CI/CD + +**File**: `.github/workflows/continuous_delivery.yml` + +**Changes**: + +1. Update Docker registry from Docker Hub to GitHub Container Registry: + +```yaml +- name: Log in to Docker Hub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} +``` + +2. Add Chrome version and release flag to build step: + +```yaml +- name: Build and push Docker image for release + env: + LINUX_AMD64_RELEASE: "true" + run: | + make release CHROME_VERSION=127.0.6533.119-1 GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REGISTRY=ghcr.io/fulll DOCKER_REPOSITORY=gotenberg +``` + +**Specifics**: +- `CHROME_VERSION=127.0.6533.119-1` (pinned version) +- `LINUX_AMD64_RELEASE="true"` +- Registry: `ghcr.io/fulll` +- Repository: `gotenberg` + +3. Add AWS ECR deployment steps: + +```yaml +- name: generate aws credentials config + env: + AWS_CREDENTIALS: ${{ secrets.STAGING_AWS_CREDENTIALS }} + aws-region: eu-central-1 + run: | + mkdir -p "${HOME}/.aws" + echo "${AWS_CREDENTIALS}" > "${HOME}/.aws/credentials" + +- name: docker login and push + run: | + # Extract tag name and strip first letter + TAG_NAME=$(echo "${{ github.event.release.tag_name }}" | cut -c 2-) + + docker pull ghcr.io/fulll/gotenberg:${TAG_NAME}-cloudrun + docker tag ghcr.io/fulll/gotenberg:${TAG_NAME}-cloudrun 285715278780.dkr.ecr.eu-central-1.amazonaws.com/gotenberg-fulll:${TAG_NAME}-cloudrun + aws --region eu-central-1 ecr get-login-password | docker login --username AWS --password-stdin 285715278780.dkr.ecr.eu-central-1.amazonaws.com + docker tag 285715278780.dkr.ecr.eu-central-1.amazonaws.com/gotenberg-fulll:${TAG_NAME}-cloudrun 285715278780.dkr.ecr.eu-central-1.amazonaws.com/gotenberg-fulll:latest + docker push 285715278780.dkr.ecr.eu-central-1.amazonaws.com/gotenberg-fulll:${TAG_NAME}-cloudrun + docker push 285715278780.dkr.ecr.eu-central-1.amazonaws.com/gotenberg-fulll:latest +``` + +**Logic**: +- Setup AWS credentials from secrets +- Extract release tag (remove 'v' prefix) +- Pull cloudrun variant from GitHub Container Registry +- Tag for AWS ECR (both versioned and latest) +- Push to ECR in eu-central-1 region + +**ECR Details**: +- Account ID: `285715278780` +- Region: `eu-central-1` +- Repository: `gotenberg-fulll` + +--- + +## API Usage + +### Request Parameters + +Users can now provide bookmarks when converting HTML/Markdown to PDF via Chromium routes: + +**Form Field**: `bookmarks` (string, JSON format) + +**Format**: JSON string matching pdfcpu BookmarkTree structure + +**Example**: +```json +{ + "Bookmarks": [ + { + "Title": "Chapter 1", + "PageFrom": 1, + "PageThru": -1, + "Kids": [ + { + "Title": "Section 1.1", + "PageFrom": 2, + "PageThru": -1 + } + ] + } + ] +} +``` + +**Behavior**: +- Bookmarks are only imported if `generateDocumentOutline` is `true` +- If bookmarks field is empty/missing, no bookmarks are added +- Invalid JSON returns error to user + +--- + +## Implementation Notes and Clarifications + +1. **Test Coverage**: + - In `pdfengines_test.go`, the commented-out assertions for `expectedImportBookmarksPdfEngines` are intentional + - No additional test implementation is required beyond what's shown + - Keep the commented code as-is + +2. **Debug Logging**: + - The `ImportBookmarksStub` function includes: `fmt.Println("ImportBookmarksStub BM empty")` + - **Keep this logging statement** - it's intentional for debugging purposes + +3. **Bookmark Validation**: + - No additional validation of bookmark structure is needed beyond JSON unmarshaling + - pdfcpu handles its own validation + - Keep the current simple approach + +4. **Implementation Approach**: + - The current approach (marshal to JSON โ†’ write temp file โ†’ call pdfcpu CLI) is intentional + - **Keep this approach** - do not refactor to use pdfcpu's Go API directly + - This maintains consistency with how other PDF operations are handled + +5. **Multi-Architecture Support**: + - **Linux AMD64 only** is intentional and required + - The project is customized for specific deployment needs + - Do not attempt to restore multi-arch support + +6. **AWS ECR Deployment**: + - AWS ECR push steps are **required and must be kept** + - This is for the project's specific deployment pipeline + - All AWS-related configuration should be preserved as-is + +7. **Chrome Version Pinning**: + - Chrome version **must be pinned** to a specific version for reproducible builds + - This allows control over Chrome updates in case new versions introduce breaking changes + - When reimplementing, update to the latest available stable Chrome version at that time, but keep it fixed (not "latest") + - Example: If current version is `127.0.6533.119-1`, find the latest stable version and pin to that specific version number + - Check https://dl.google.com/linux/chrome/deb/dists/stable/main/binary-amd64/Packages for available versions + +--- + +## Implementation Checklist + +When reimplementing on a newer version: + +- [ ] Add pdfcpu dependency to go.mod +- [ ] Extend PdfEngine interface with ImportBookmarks method +- [ ] Implement ImportBookmarks in pdfcpu module +- [ ] Add stub implementations in other PDF engines +- [ ] Update mock implementations +- [ ] Add multi-engine support for import bookmarks +- [ ] Add configuration flag for import bookmarks engines +- [ ] Update PdfEngines module to handle import bookmarks +- [ ] Add ImportBookmarksStub helper function +- [ ] Add Bookmarks field to Chromium PdfOptions +- [ ] Add bookmarks form field parsing in Chromium routes +- [ ] Integrate bookmark import in convertUrl function +- [ ] Update all test files with new parameters +- [ ] Add Chrome version build argument to Dockerfile +- [ ] Update Makefile with CHROME_VERSION support +- [ ] Update release script +- [ ] (Optional) Update CI/CD for specific deployment needs +- [ ] Test bookmark import with sample pdfcpu bookmark JSON +- [ ] Verify all PDF engines return appropriate errors +- [ ] Validate multi-engine fallback behavior + +--- + +## References + +- pdfcpu documentation: https://github.com/pdfcpu/pdfcpu +- pdfcpu bookmark format: See pdfcpu CLI documentation for `bookmarks export` command output format +- Original commit: `67c02e41cc185765ca4775a82556d55aaf882e8f` diff --git a/LICENSE b/LICENSE index 0f9e9ccfa..5a6f068cd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Julien Neuhart +Copyright (c) Julien Neuhart Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index def2bb19b..d8272d52b 100644 --- a/Makefile +++ b/Makefile @@ -1,42 +1,26 @@ +include .env + .PHONY: help help: ## Show the help - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -.PHONY: it -it: build build-tests ## Initialize the development environment - -GOLANG_VERSION=1.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. + @grep -hE '^[A-Za-z0-9_ \-]*?:.*##.*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' .PHONY: build build: ## Build the Gotenberg's Docker image docker build \ - --build-arg GOLANG_VERSION=$(GOLANG_VERSION) \ - --build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \ - --build-arg GOTENBERG_USER_GID=$(GOTENBERG_USER_GID) \ - --build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \ - --build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \ - --build-arg PDFTK_VERSION=$(PDFTK_VERSION) \ - --build-arg PDFCPU_VERSION=$(PDFCPU_VERSION) \ -t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \ - -f build/Dockerfile . + -t gotenberg/gotenberg:snapshot \ + -f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT) +GOTENBERG_HIDE_BANNER=false GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s +GOTENBERG_BUILD_DEBUG_DATA=true API_PORT=3000 API_PORT_FROM_ENV= API_BIND_IP= API_START_TIMEOUT=30s API_TIMEOUT=30s API_BODY_LIMIT= -API_ROOT_PATH=/ +API_ROOT_PATH="/" API_TRACE_HEADER=Gotenberg-Trace API_ENABLE_BASIC_AUTH=false GOTENBERG_API_BASIC_AUTH_USERNAME= @@ -46,11 +30,11 @@ API-DOWNLOAD-FROM-DENY-LIST= API-DOWNLOAD-FROM-FROM-MAX-RETRY=4 API-DISABLE-DOWNLOAD-FROM=false API_DISABLE_HEALTH_CHECK_LOGGING=false -CHROMIUM_RESTART_AFTER=0 +API_ENABLE_DEBUG_ROUTE=false +CHROMIUM_RESTART_AFTER=10 CHROMIUM_MAX_QUEUE_SIZE=0 CHROMIUM_AUTO_START=false CHROMIUM_START_TIMEOUT=20s -CHROMIUM_INCOGNITO=false CHROMIUM_ALLOW_INSECURE_LOCALHOST=false CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false CHROMIUM_DISABLE_WEB_SECURITY=false @@ -71,16 +55,21 @@ LIBREOFFICE_DISABLE_ROUTES=false LOG_LEVEL=info LOG_FORMAT=auto LOG_FIELDS_PREFIX= -PDFENGINES_ENGINES= +LOG_ENABLE_GCP_FIELDS=false PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk +PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk +PDFENGINES_FLATTEN_ENGINES=qpdf PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine PDFENGINES_READ_METADATA_ENGINES=exiftool PDFENGINES_WRITE_METADATA_ENGINES=exiftool +PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk PDFENGINES_DISABLE_ROUTES=false +PDFENGINES_EMBED_ENGINES=pdfcpu PROMETHEUS_NAMESPACE=gotenberg PROMETHEUS_COLLECT_INTERVAL=1s PROMETHEUS_DISABLE_ROUTE_LOGGING=false PROMETHEUS_DISABLE_COLLECT=false +WEBHOOK_ENABLE_SYNC_MODE=false WEBHOOK_ALLOW_LIST= WEBHOOK_DENY_LIST= WEBHOOK_ERROR_ALLOW_LIST= @@ -99,7 +88,9 @@ run: ## Start a Gotenberg container -e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \ $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \ gotenberg \ + --gotenberg-hide-banner=$(GOTENBERG_HIDE_BANNER) \ --gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \ + --gotenberg-build-debug-data="$(GOTENBERG_BUILD_DEBUG_DATA)" \ --api-port=$(API_PORT) \ --api-port-from-env=$(API_PORT_FROM_ENV) \ --api-bind-ip=$(API_BIND_IP) \ @@ -114,11 +105,11 @@ run: ## Start a Gotenberg container --api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \ --api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \ --api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \ + --api-enable-debug-route=$(API_ENABLE_DEBUG_ROUTE) \ --chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \ --chromium-auto-start=$(CHROMIUM_AUTO_START) \ --chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \ --chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \ - --chromium-incognito=$(CHROMIUM_INCOGNITO) \ --chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \ --chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \ --chromium-disable-web-security=$(CHROMIUM_DISABLE_WEB_SECURITY) \ @@ -139,16 +130,21 @@ run: ## Start a Gotenberg container --log-level=$(LOG_LEVEL) \ --log-format=$(LOG_FORMAT) \ --log-fields-prefix=$(LOG_FIELDS_PREFIX) \ - --pdfengines-engines=$(PDFENGINES_ENGINES) \ + --log-enable-gcp-fields=$(LOG_ENABLE_GCP_FIELDS) \ --pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \ + --pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \ + --pdfengines-flatten-engines=$(PDFENGINES_FLATTEN_ENGINES) \ --pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \ --pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \ --pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \ + --pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \ --pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \ + --pdfengines-embed-engines=$(PDFENGINES_EMBED_ENGINES) \ --prometheus-namespace=$(PROMETHEUS_NAMESPACE) \ --prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \ --prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \ --prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \ + --webhook-enable-sync-mode="$(WEBHOOK_ENABLE_SYNC_MODE)" \ --webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \ --webhook-deny-list="$(WEBHOOK_DENY_LIST)" \ --webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \ @@ -159,58 +155,75 @@ run: ## Start a Gotenberg container --webhook-client-timeout=$(WEBHOOK_CLIENT_TIMEOUT) \ --webhook-disable=$(WEBHOOK_DISABLE) -.PHONY: build-tests -build-tests: ## Build the tests' Docker image - docker build \ - --build-arg GOLANG_VERSION=$(GOLANG_VERSION) \ - --build-arg DOCKER_REGISTRY=$(DOCKER_REGISTRY) \ - --build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \ - --build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \ - --build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \ - -t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \ - -f test/Dockerfile . +.PHONY: test-unit +test-unit: ## Run unit tests + go test -race ./... -.PHONY: tests -tests: ## Start the testing environment - docker run --rm -it \ - -v $(PWD):/tests \ - $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \ - bash +PLATFORM= +NO_CONCURRENCY=false +# Available tags: +# chromium +# chromium-convert-html +# chromium-convert-markdown +# chromium-convert-url +# debug +# health +# libreoffice +# libreoffice-convert +# output-filename +# pdfengines +# pdfengines-convert +# pdfengines-embed +# embed +# pdfengines-encrypt +# encrypt +# pdfengines-flatten +# flatten +# pdfengines-merge +# merge +# pdfengines-metadata +# metadata +# pdfengines-split +# split +# prometheus-metrics +# root +# version +# webhook +# download-from +TAGS= + +.PHONY: test-integration +test-integration: ## Run integration tests + go test -timeout 40m -tags=integration -v github.com/gotenberg/gotenberg/v8/test/integration -args \ + --gotenberg-docker-repository=$(DOCKER_REPOSITORY) \ + --gotenberg-version=$(GOTENBERG_VERSION) \ + --gotenberg-container-platform=$(PLATFORM) \ + --no-concurrency=$(NO_CONCURRENCY) \ + --tags="$(TAGS)" -.PHONY: tests-once -tests-once: ## Run the tests once (prefer the "tests" command while developing) - docker run --rm \ - -v $(PWD):/tests \ - $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \ - gotest +.PHONY: lint +lint: ## Lint Golang codebase + golangci-lint run + +.PHONY: lint-prettier +lint-prettier: ## Lint non-Golang codebase + npx prettier --check . + +.PHONY: lint-todo +lint-todo: ## Find TODOs in Golang codebase + golangci-lint run --no-config --disable-all --enable godox -# go install mvdan.cc/gofumpt@latest -# go install github.com/daixiang0/gci@latest .PHONY: fmt -fmt: ## Format the code and "optimize" the dependencies - gofumpt -l -w . - gci write -s standard -s default -s "prefix(github.com/gotenberg/gotenberg/v8)" --skip-generated --skip-vendor --custom-order . +fmt: ## Format Golang codebase and "optimize" the dependencies + golangci-lint fmt go mod tidy +.PHONY: prettify +prettify: ## Format non-Golang codebase + npx prettier --write . + # go install golang.org/x/tools/cmd/godoc@latest .PHONY: godoc godoc: ## Run a webserver with Gotenberg godoc $(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8) godoc -http=:6060 - -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) - diff --git a/README.md b/README.md index 30a702754..9f7b6c4d8 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,22 @@

Gotenberg Logo

Gotenberg

-

A Docker-powered stateless API for PDF files

+

A containerized API for seamless PDF conversion

Total downloads (gotenberg/gotenberg) Total downloads (thecodingmachine/gotenberg) -
- Continuous Integration + Continuous Integration Go Reference - Code coverage +

+

+ gotenberg%2Fgotenberg | Trendshift

Documentation · Live Demo ๐Ÿ”ฅ

--- -**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting +**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more! ## Quick Start @@ -42,9 +43,21 @@ Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduct TheCodingMachine Logo - - Zolsec Logo + + pdfme Logo

-Sponsorships help maintaining and improving Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) โค๏ธ +Sponsorships help maintain and improve Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) โค๏ธ + +--- + +

+ Powered by +

+ +

+ + JetBrains logo + +

diff --git a/SECURITY.md b/SECURITY.md index 43b1ab4f2..f281db277 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,25 +2,25 @@ ## Supported Versions -Please ensure to keep your environment up-to-date and use only the latest version of Gotenberg. +Please ensure to keep your environment up to date and use only the latest version of Gotenberg. Security updates and patches will be applied only to the most recent version. ## Reporting a Vulnerability -Your help in identifying vulnerabilities in our project is much appreciated. +Your help in identifying vulnerabilities in our project is much appreciated. We take all reports regarding security seriously. -If you discover a security vulnerability, please refrain from publishing it publicly. -Instead, kindly send us the details via email to *neuhart [dot] julien [at] gmail [dot] com*. +If you discover a security vulnerability, please refrain from publishing it publicly. +Instead, kindly send us the details via email to _neuhart [dot] julien [at] gmail [dot] com_. -In the subject of your email, please indicate that it's a security vulnerability report for Gotenberg. +In the subject of your email, please indicate that it's a security vulnerability report for Gotenberg. In your message, please include: -* A detailed description of the vulnerability. -* The steps to reproduce the issue. -* Any potential impact of the vulnerability on the users or system. +- A detailed description of the vulnerability. +- The steps to reproduce the issue. +- Any potential impact of the vulnerability on the users or system. -Please remember that this process is done in a *'best-effort'* manner. +Please remember that this process is done in a _'best-effort'_ manner. This means we strive to respond and act as quickly as possible, but the speed may vary depending on the severity of the issue and our resources. @@ -28,14 +28,14 @@ Thank you in advance for helping to keep our project safe! ## Disclosure Policy -Once we have received your vulnerability report, we will work to validate and reproduce the issue. +Once we have received your vulnerability report, we will work to validate and reproduce the issue. If we can confirm the vulnerability, we will proceed to: -* Work on a fix and a release timeline. -* Notify you when the fix has been implemented and released. -* Credit you for discovering the vulnerability (unless you request anonymity). -* Please note that we will do our best to keep you informed about the progress towards resolving the issue. +- Work on a fix and a release timeline. +- Notify you when the fix has been implemented and released. +- Credit you for discovering the vulnerability (unless you request anonymity). +- Please note that we will do our best to keep you informed about the progress towards resolving the issue. ## Comments on this Policy -If you have suggestions on how this process could be improved, please submit a pull request. \ No newline at end of file +If you have suggestions on how this process could be improved, please submit a pull request. diff --git a/build/Dockerfile b/build/Dockerfile index 38771906b..ceb952c70 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,17 +1,18 @@ # ARG instructions do not create additional layers. Instead, next layers will # concatenate them. Also, we have to repeat ARG instructions in each build # stage that uses them. -ARG GOLANG_VERSION +ARG GOLANG_VERSION=1.25.4 +ARG CHROME_VERSION=143.0.7499.109-1 # ---------------------------------------------- # pdfcpu binary build stage # ---------------------------------------------- # Note: this stage is required as pdfcpu does not release an armhf variant by # default. - FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage -ARG PDFCPU_VERSION +# See https://github.com/pdfcpu/pdfcpu/releases. +ARG PDFCPU_VERSION=v0.11.1 ENV CGO_ENABLED=0 # Define the working directory outside of $GOPATH (we're using go modules). @@ -33,7 +34,7 @@ RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'git # ---------------------------------------------- FROM golang:$GOLANG_VERSION AS gotenberg-binary-stage -ARG GOTENBERG_VERSION +ARG GOTENBERG_VERSION=snapshot ENV CGO_ENABLED=0 # Define the working directory outside of $GOPATH (we're using go modules). @@ -49,26 +50,60 @@ RUN go mod download &&\ COPY cmd ./cmd COPY pkg ./pkg -RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go +RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go + +# ---------------------------------------------- +# Custom JRE stage +# Credits: https://github.com/jodconverter/docker-image-jodconverter-runtime +# ---------------------------------------------- +FROM debian:13-slim AS custom-jre-stage + +RUN \ + apt-get update -qq &&\ + apt-get upgrade -yqq &&\ + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils + +# Note: jdeps helps finding which modules a JAR requires. +# Currently only for PDFtk, as we don't rely on LibreOffice UNO Java SDK. +ENV JAVA_MODULES=java.base,java.desktop,java.naming,java.sql + +RUN jlink \ + --add-modules $JAVA_MODULES \ + --strip-debug \ + --no-man-pages \ + --no-header-files \ + --compress=2 \ + --output /custom-jre + +# ---------------------------------------------- +# Base image stage +# ---------------------------------------------- +FROM debian:13-slim AS base-image-stage + +COPY --from=custom-jre-stage /custom-jre /opt/java + +ENV PATH="/opt/java/bin:${PATH}" # ---------------------------------------------- # Final stage # ---------------------------------------------- -FROM debian:12-slim +FROM base-image-stage -ARG GOTENBERG_VERSION -ARG GOTENBERG_USER_GID -ARG GOTENBERG_USER_UID -ARG NOTO_COLOR_EMOJI_VERSION -ARG PDFTK_VERSION -ARG TMP_CHOMIUM_VERSION_ARMHF="116.0.5845.180-1~deb12u1" +ARG GOTENBERG_VERSION=snapshot +ARG GOTENBERG_USER_GID=1001 +ARG GOTENBERG_USER_UID=1001 +ARG CHROME_VERSION +# See https://github.com/googlefonts/noto-emoji/releases. +ARG NOTO_COLOR_EMOJI_VERSION=v2.051 +# See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package. +ARG PDFTK_VERSION=v3.3.3 LABEL org.opencontainers.image.title="Gotenberg" \ - org.opencontainers.image.description="A Docker-powered stateless API for PDF files." \ - org.opencontainers.image.version="$GOTENBERG_VERSION" \ - org.opencontainers.image.authors="Julien Neuhart " \ - org.opencontainers.image.documentation="https://gotenberg.dev" \ - org.opencontainers.image.source="https://github.com/gotenberg/gotenberg" + org.opencontainers.image.description="A containerized API for seamless PDF conversion." \ + org.opencontainers.image.version="$GOTENBERG_VERSION" \ + org.opencontainers.image.authors="Julien Neuhart " \ + org.opencontainers.image.documentation="https://gotenberg.dev" \ + org.opencontainers.image.source="https://github.com/gotenberg/gotenberg" RUN \ # Create a non-root user. @@ -82,7 +117,8 @@ RUN \ # Install system dependencies required for the next instructions or debugging. # Note: tini is a helper for reaping zombie processes. apt-get update -qq &&\ - DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 default-jre-headless &&\ + apt-get upgrade -yqq &&\ + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 python3-distutils-extra &&\ # Cleanup. # Note: the Debian image does automatically a clean after each install thanks to a hook. # Therefore, there is no need for apt-get clean. @@ -96,6 +132,7 @@ RUN \ # https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html. curl -o ./ttf-mscorefonts-installer_3.8.1_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8.1_all.deb &&\ apt-get update -qq &&\ + apt-get upgrade -yqq &&\ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ ./ttf-mscorefonts-installer_3.8.1_all.deb \ culmus \ @@ -122,7 +159,6 @@ RUN \ fonts-crosextra-caladea \ fonts-crosextra-carlito \ fonts-dejavu \ - fonts-dejavu-extra \ fonts-liberation \ fonts-liberation2 \ fonts-linuxlibertine \ @@ -142,30 +178,43 @@ RUN \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* RUN \ - # Install either Google Chrome stable on amd64 architecture or - # Chromium on other architectures. - # See https://github.com/gotenberg/gotenberg/issues/328. - # FIXME: - # armhf is currently not working with the latest version of Chromium. - # See: https://github.com/gotenberg/gotenberg/issues/709. + # Install Hyphenation for LibreOffice. + # Credits: https://wiki.archlinux.org/title/LibreOffice. + apt-get update -qq &&\ + apt-get upgrade -yqq &&\ + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ + hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \ + hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \ + hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \ + hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \ + hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu &&\ + # Cleanup. + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +RUN \ + # Install Chromium. /bin/bash -c \ 'set -e &&\ if [[ "$(dpkg --print-architecture)" == "amd64" ]]; then \ - curl https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\ - echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\ apt-get update -qq &&\ - DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\ - mv /usr/bin/google-chrome-stable /usr/bin/chromium; \ + if [ -z "$CHROME_VERSION" ]; then \ + # Install latest stable version (use gpg dearmor instead of apt-key) + curl -fsSL https://dl.google.com/linux/linux_signing_key.pub -o /usr/share/keyrings/google-linux-signing-keyring.gpg &&\ + echo "deb [signed-by=/usr/share/keyrings/google-linux-signing-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\ + apt-get update -qq &&\ + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\ + mv /usr/bin/google-chrome-stable /usr/bin/chromium; \ + else \ + # Install specific version + apt-get update -qq &&\ + curl --output /tmp/chrome.deb "https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb" &&\ + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends /tmp/chrome.deb &&\ + mv /usr/bin/google-chrome-stable /usr/bin/chromium &&\ + rm -rf /tmp/chrome.deb; \ + fi \ 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 \ - apt-get update -qq &&\ + apt-get upgrade -yqq &&\ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \ fi' &&\ # Verify installation. @@ -173,12 +222,20 @@ RUN \ # Cleanup. rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +# Set default characterset encoding to UTF-8. +# See: +# https://github.com/gotenberg/gotenberg/issues/104 +# https://github.com/gotenberg/gotenberg/issues/730 +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 + RUN \ - # Install LibreOffice & unoconverter. - echo "deb http://deb.debian.org/debian bookworm-backports main" >> /etc/apt/sources.list &&\ + # Install LibreOffice & unoconverter. \ + echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list &&\ apt-get update -qq &&\ - 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 &&\ + apt-get upgrade -yqq &&\ + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports libreoffice &&\ + curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.1.1/unoconv -o /usr/bin/unoconverter &&\ chmod +x /usr/bin/unoconverter &&\ # unoconverter will look for the Python binary, which has to be at version 3. ln -s /usr/bin/python3 /usr/bin/python &&\ @@ -193,9 +250,10 @@ RUN \ # See https://github.com/gotenberg/gotenberg/pull/273. curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\ chmod a+x /usr/bin/pdftk-all.jar &&\ - echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \ + printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \ chmod +x /usr/bin/pdftk &&\ apt-get update -qq &&\ + apt-get upgrade -yqq &&\ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\ # See https://github.com/nextcloud/docker/issues/380. mkdir -p /usr/share/man/man1 &&\ @@ -206,20 +264,32 @@ RUN \ # Cleanup. rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +# Support for arbitrary user IDs (OpenShift). +# See: +# https://github.com/gotenberg/gotenberg/issues/1049. +# https://docs.redhat.com/en/documentation/openshift_container_platform/4.15/html/images/creating-images#use-uid_create-images. +RUN \ + usermod -aG root gotenberg &&\ + chgrp -R 0 /home/gotenberg &&\ + chmod -R g=u /home/gotenberg + # Improve fonts subpixel hinting and smoothing. # Credits: # https://github.com/arachnys/athenapdf/issues/69. # https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d. COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf -# Copy the pdfcpu binary from the pdfcpu-binary-stage. -COPY --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/ +# Copy dictionnaries so that hypens work on Chromium. +# See https://github.com/gotenberg/gotenberg/issues/1293. +COPY --chown=gotenberg:gotenberg build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data -# Copy the Gotenberg binary from the gotenberg-binary-stage. +# Copy the Golang binaries. +COPY --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/ COPY --from=gotenberg-binary-stage /home/gotenberg /usr/bin/ # Environment variables required by modules or else. ENV CHROMIUM_BIN_PATH=/usr/bin/chromium +ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter ENV PDFTK_BIN_PATH=/usr/bin/pdftk diff --git a/build/Dockerfile.aws-lambda b/build/Dockerfile.aws-lambda new file mode 100644 index 000000000..858f2a38e --- /dev/null +++ b/build/Dockerfile.aws-lambda @@ -0,0 +1,21 @@ +ARG DOCKER_REGISTRY +ARG DOCKER_REPOSITORY +ARG GOTENBERG_VERSION + +FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION + +USER root + +COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter + +# AWS. +ENV AWS_LWA_PORT=3000 +ENV AWS_LWA_READINESS_CHECK_PATH=/health +ENV AWS_LWA_INVOKE_MODE=buffered + +# Gotenberg. +ENV API_PORT_FROM_ENV=AWS_LWA_PORT +ENV WEBHOOK_ENABLE_SYNC_MODE=true +ENV GOTENBERG_BUILD_DEBUG_DATA=false + +USER gotenberg diff --git a/build/Dockerfile.cloudrun b/build/Dockerfile.cloudrun index 0298ae938..7e68a6cc6 100644 --- a/build/Dockerfile.cloudrun +++ b/build/Dockerfile.cloudrun @@ -11,4 +11,12 @@ USER root # See https://github.com/gotenberg/gotenberg/issues/90#issuecomment-543551353. RUN chown gotenberg: /usr/bin/tini +# Gotenberg. +ENV API_PORT_FROM_ENV=PORT +ENV CHROMIUM_AUTO_START=true +ENV LIBREOFFICE_AUTO_START=true +ENV WEBHOOK_ENABLE_SYNC_MODE=true +ENV GOTENBERG_BUILD_DEBUG_DATA=false +ENV LOG_ENABLE_GCP_FIELDS=true + USER gotenberg diff --git a/build/chromium-hyphen-data/120.0.6050.0/_metadata/verified_contents.json b/build/chromium-hyphen-data/120.0.6050.0/_metadata/verified_contents.json new file mode 100644 index 000000000..b5cf54546 --- /dev/null +++ b/build/chromium-hyphen-data/120.0.6050.0/_metadata/verified_contents.json @@ -0,0 +1 @@ +[{"description":"treehash per file","signed_content":{"payload":"eyJjb250ZW50X2hhc2hlcyI6W3siYmxvY2tfc2l6ZSI6NDA5NiwiZGlnZXN0Ijoic2hhMjU2IiwiZmlsZXMiOlt7InBhdGgiOiJoeXBoLWFmLmh5YiIsInJvb3RfaGFzaCI6ImU3S1ZpWjlhODYwT3ZfdHR1dTRDME9JODlGQUNkcjR0Z01lOGhnNU1xVUkifSx7InBhdGgiOiJoeXBoLWFzLmh5YiIsInJvb3RfaGFzaCI6InduaE9NeFdLZ0hFMWhROXhKYWZxcS1SeXM4X0hyN2dzZFBBdHBwNmlVUDQifSx7InBhdGgiOiJoeXBoLWJlLmh5YiIsInJvb3RfaGFzaCI6IlpLdnllRTdIQmlLMktnYjBwRUUzVnotRmZ4RlJoQVNQcUJHeXlCbGtkaDAifSx7InBhdGgiOiJoeXBoLWJnLmh5YiIsInJvb3RfaGFzaCI6ImRaUHdPVkNCNC02eTJGRnRFSFJtQ0tfWUpzXzlUbjQzMVRrMm1UMGdDaE0ifSx7InBhdGgiOiJoeXBoLWJuLmh5YiIsInJvb3RfaGFzaCI6InduaE9NeFdLZ0hFMWhROXhKYWZxcS1SeXM4X0hyN2dzZFBBdHBwNmlVUDQifSx7InBhdGgiOiJoeXBoLWNzLmh5YiIsInJvb3RfaGFzaCI6IklnUndJWmZEOFctRjdYbExMMHJ4TTdkYTVRc3FVQlVwS2F5SkdodlVfRXcifSx7InBhdGgiOiJoeXBoLWN1Lmh5YiIsInJvb3RfaGFzaCI6ImFiWlhPbWx5T0dnSEplVWlHMkhaQURadHA3dlM2QnI3RGh3TUF0eWV4N2sifSx7InBhdGgiOiJoeXBoLWN5Lmh5YiIsInJvb3RfaGFzaCI6Ims5Y1JTUUhCNDNiNlVNaHN6cE5nN3k2cGliTVZGOFJnQjk3MmpQVGNvYkEifSx7InBhdGgiOiJoeXBoLWRhLmh5YiIsInJvb3RfaGFzaCI6IlRMZk92MjdUTFFpSDdWaFNIbDlCblQydDlKSkl1WEpDMWlFWUxRS251bGcifSx7InBhdGgiOiJoeXBoLWRlLTE5MDEuaHliIiwicm9vdF9oYXNoIjoiMHlHekNnc2tpTGI1STJoTC0yc1FCVmJMXzNCekE4VFNwSUZ6aDltd1ZsYyJ9LHsicGF0aCI6Imh5cGgtZGUtMTk5Ni5oeWIiLCJyb290X2hhc2giOiJIMGVZZHhlbDNyZU15UHRqVEt2QUI4RWFzaEFTbGpMUmhZOU83c0ljUFVRIn0seyJwYXRoIjoiaHlwaC1kZS1jaC0xOTAxLmh5YiIsInJvb3RfaGFzaCI6InpMQVlIVGVvc3IwdlBrcTc2VjdJM083b0V1cUI5M3NtSmxqNThibjZuYWMifSx7InBhdGgiOiJoeXBoLWVsLmh5YiIsInJvb3RfaGFzaCI6IjFOazV4S1JiR1ZYVElCUkVIbjB2SFJzU1VNTjZfdDAzdTVtRkwzMEtNN3MifSx7InBhdGgiOiJoeXBoLWVuLWdiLmh5YiIsInJvb3RfaGFzaCI6IlZvR2ZOaHpnajBOQ29qelhscjBQdjFSdnpFTEZJVFJ3MURRTWRUMXZiT0kifSx7InBhdGgiOiJoeXBoLWVuLXVzLmh5YiIsInJvb3RfaGFzaCI6Il94OUFGM2dFMzBLelE0bHFRU1BqLWZXWnl0bnNqLURWQVgzdDRqZEVUVXMifSx7InBhdGgiOiJoeXBoLWVzLmh5YiIsInJvb3RfaGFzaCI6IjBmdWc0YWVadDc0Z19XbEVyNUtsY1JHWkVkMzJXZFEtWFptSkxZX2xuRWsifSx7InBhdGgiOiJoeXBoLWV0Lmh5YiIsInJvb3RfaGFzaCI6ImxkUFIwUm14R3EyZ3EzNFF1Ylp6LXRlRGtvWFFibmg4VjM2bjIyRkNxY0EifSx7InBhdGgiOiJoeXBoLWV1Lmh5YiIsInJvb3RfaGFzaCI6IjRuZUtUOGU0OEdTaksycEV2Q254RGlaTm5XSVV1TzI0NjlIMTl0YU9MckkifSx7InBhdGgiOiJoeXBoLWZyLmh5YiIsInJvb3RfaGFzaCI6IjFudGF1Nm9FVUtQbWV2SFJKSkwydEc5c1FYQmxOcHFSZFJxYlZpMnJZeDAifSx7InBhdGgiOiJoeXBoLWdhLmh5YiIsInJvb3RfaGFzaCI6ImxGLVlGb3VwcUItempfM1ZadFc0aEw4Uk51Ql9YREpna0p2N1VMMFJFc1kifSx7InBhdGgiOiJoeXBoLWdsLmh5YiIsInJvb3RfaGFzaCI6IlJBU1hfb0MxVzFDUmtOYURETC0xZVoxYnYyS0c0Y2hfWE1jUEU4cXRpY1kifSx7InBhdGgiOiJoeXBoLWd1Lmh5YiIsInJvb3RfaGFzaCI6InJ3N2JaOElobTRBOFByYkIzdWJ5MUJvXzRBUm9xZHFMNk85UVZ0Y0JxX00ifSx7InBhdGgiOiJoeXBoLWhpLmh5YiIsInJvb3RfaGFzaCI6IjlOOGlUVVdmMFJGcGpkV2hOaFBGdV9EdEVmQkNlTllDTU5Bb0FRNnNERUkifSx7InBhdGgiOiJoeXBoLWhyLmh5YiIsInJvb3RfaGFzaCI6IjFmQm1wV1ZfSFh3NTBGT1ZiZklFdDVKdlFOTC1UMmxYT3ZDZGtKQm00bXcifSx7InBhdGgiOiJoeXBoLWh1Lmh5YiIsInJvb3RfaGFzaCI6InExWmRIaTR3VElWbFFiSHhVdW5NVEJaaEMya29JWTg1d3pUTnE0aUhTVlEifSx7InBhdGgiOiJoeXBoLWh5Lmh5YiIsInJvb3RfaGFzaCI6Im16VGZ5b1hMSjFSb0tmRUU4VGQxZnZzblNUVEI2ZFNaSDFXdFZrbGlwMm8ifSx7InBhdGgiOiJoeXBoLWl0Lmh5YiIsInJvb3RfaGFzaCI6Ii1jQW4xXzFFc0J6VjRjMzRBdUlNWTFZR2N3bUs4WXZxQ1RDNm12TTA0UGMifSx7InBhdGgiOiJoeXBoLWthLmh5YiIsInJvb3RfaGFzaCI6IlZoTFVGQnBOSDg5RDU2WXVPRmx4dnRqTTBJcjZfVTRLMUJacXB6NzVmaTAifSx7InBhdGgiOiJoeXBoLWtuLmh5YiIsInJvb3RfaGFzaCI6Iks1bWRDaFV2Z0VZQnFvODRfdzA2YmxsSmwzdngycWR2cUlpc3JpRlNZb2MifSx7InBhdGgiOiJoeXBoLWxhLmh5YiIsInJvb3RfaGFzaCI6Il9VdHZOaE5jMDdreTQxRHNJQmZmMkowdU5xd2liMVRreVBMa3ZHMndXVDAifSx7InBhdGgiOiJoeXBoLWx0Lmh5YiIsInJvb3RfaGFzaCI6Il9pbnpod2o5ZEtMZ3NOeDdVOHV1TGE4WVlXZUFnZVZQb2pVVUJ2eVZPUkUifSx7InBhdGgiOiJoeXBoLWx2Lmh5YiIsInJvb3RfaGFzaCI6Imtkc0Ytd1FuNHpQQzNySW83ekw0UUZLNlJ4NkNZVjZmVkhzd3dBM0tDV2MifSx7InBhdGgiOiJoeXBoLW1sLmh5YiIsInJvb3RfaGFzaCI6ImtGY3R1UFNiQWV4cUVDY3l6ZkZQd19COU5qeS1EU1lSQS1XREJERms2SWcifSx7InBhdGgiOiJoeXBoLW1uLWN5cmwuaHliIiwicm9vdF9oYXNoIjoiMm5yb3g2UFNHU19XQ1FZWUk3SnZ0cWwxMlhjUHVTd3UxMk1aS2VMT1QzayJ9LHsicGF0aCI6Imh5cGgtbXIuaHliIiwicm9vdF9oYXNoIjoiOU44aVRVV2YwUkZwamRXaE5oUEZ1X0R0RWZCQ2VOWUNNTkFvQVE2c0RFSSJ9LHsicGF0aCI6Imh5cGgtbXVsLWV0aGkuaHliIiwicm9vdF9oYXNoIjoiOHZyQnZRYWZfbHpSRVMyVXpERVRmdE9LR3hZUWstelhUSndXaUVLTGFJcyJ9LHsicGF0aCI6Imh5cGgtbmIuaHliIiwicm9vdF9oYXNoIjoidW1oN2VNX0ptaVRpdVdjeUNSU2Y0eGVnT085aDZaczZxcl9XeHdtQk9IdyJ9LHsicGF0aCI6Imh5cGgtbmwuaHliIiwicm9vdF9oYXNoIjoiMWNMSjEtZ0J3UkhNMDlhVExINVZZOWpXeGY2cUpqYjgydFdSX0tsRlg5ZyJ9LHsicGF0aCI6Imh5cGgtbm4uaHliIiwicm9vdF9oYXNoIjoiVVRNblpKaGR0LW51UGEwSGRBMmpqeE9yUU9CMTZ4UVk3ZFo1b2dKeVB2MCJ9LHsicGF0aCI6Imh5cGgtb3IuaHliIiwicm9vdF9oYXNoIjoiVHB6VEFycl94T28tbGxJeWZxSkFjdXZ6ZTF4UHdIR1NrcjJzRUtxdFpscyJ9LHsicGF0aCI6Imh5cGgtcGEuaHliIiwicm9vdF9oYXNoIjoiUndNcDBvLXFTRS1VWFhqXzc3RjIzTGJ5QXl4MVBpVzhBVUVHclNTeXhvbyJ9LHsicGF0aCI6Imh5cGgtcHQuaHliIiwicm9vdF9oYXNoIjoiOXZ2eHZMSmd6SVlsYjhTVTg0ajNzbjBRaGwtX2oyRlJmZTRscjAxWTF1ZyJ9LHsicGF0aCI6Imh5cGgtcnUuaHliIiwicm9vdF9oYXNoIjoicXN2dk9SNU5oUWlrYV8zVXU5N3QwQ0tWU2o2RFhPSVFFMVVXbWRmR1VRdyJ9LHsicGF0aCI6Imh5cGgtc2suaHliIiwicm9vdF9oYXNoIjoiN2Z4MDBSMHQtYjVscVVlX3hGNy1pVThuNkZUTzJrVjNmYy1odGdEQVZlYyJ9LHsicGF0aCI6Imh5cGgtc2wuaHliIiwicm9vdF9oYXNoIjoiT1hDWTBsMS0wYzZ2eVk4YmpURTBObEJBSnlvUVl5YmFfOVp0WVN0UF83byJ9LHsicGF0aCI6Imh5cGgtc3EuaHliIiwicm9vdF9oYXNoIjoidkNuSlFCenBVa0ZNdXV2RnlPNGRKOEZ3Ykc5M2dIdGY5eFBpRWtRNHM4byJ9LHsicGF0aCI6Imh5cGgtc3YuaHliIiwicm9vdF9oYXNoIjoiR1hhQU9rUmRyWE5ac1FLbHBKX3lCd1doZUNpRzhjZFNzREZ4OWc3MnJwOCJ9LHsicGF0aCI6Imh5cGgtdGEuaHliIiwicm9vdF9oYXNoIjoiUVAycFNGYW9id1pkNkxxbUdFNm1QYzJ3RWU1TXBKaW53ZjdrVEpreFRHYyJ9LHsicGF0aCI6Imh5cGgtdGUuaHliIiwicm9vdF9oYXNoIjoiVVctcFpVLWpycXEwZ05RT3IyclhqOEE1Q0d2WTdjRkV2ajFaVWw3Y3JDayJ9LHsicGF0aCI6Imh5cGgtdGsuaHliIiwicm9vdF9oYXNoIjoiZF8ydTBwdllRcXFwZHF0LS1CdGhlaFhBb3RIcjBSWWNHX0pyZWFFSXRjMCJ9LHsicGF0aCI6Imh5cGgtdWsuaHliIiwicm9vdF9oYXNoIjoieWxjVXUzT05ZS3N1LW9pS3R5VWNSak1PQnhwZzBMdjdMNENvZHpsUW5zayJ9LHsicGF0aCI6Imh5cGgtdW5kLWV0aGkuaHliIiwicm9vdF9oYXNoIjoiSGVnOHQ0ZmZyMVA3Zm02TnM2cmxBSXJTVHIzU2ktQWdNVEJ1cWVuejRvVSJ9LHsicGF0aCI6Im1hbmlmZXN0Lmpzb24iLCJyb290X2hhc2giOiIxWEh2TTdEbkIxY2ZFTHMzdVpwZ2ZXOURyLU1fVTlGYlE1V3hidlA5cG1VIn1dLCJmb3JtYXQiOiJ0cmVlaGFzaCIsImhhc2hfYmxvY2tfc2l6ZSI6NDA5Nn1dLCJpdGVtX2lkIjoiamFtaGNubmtpaGlubWRsa2Fra2FvcGJqYmJjbmdmbGMiLCJpdGVtX3ZlcnNpb24iOiIxMjAuMC42MDUwLjAiLCJwcm90b2NvbF92ZXJzaW9uIjoxfQ","signatures":[{"header":{"kid":"publisher"},"protected":"eyJhbGciOiJSUzI1NiJ9","signature":"ud33uh3_3o_eTIMSj_MKboC9-GzBxQ-Bu6XS31wn7JB3ntcoVSUfAgMjTBCsIYEEgqVfKJlf92wgl3SbjJWaT-_XfV8sMFwZtuAT0qJV0p9gammnprPP0OmUwJdJB-kK1MO8ESwSyeGKCEeIXGDqAVdQHkYD-oKzYS-zKhe9KVnU-WtJ6mtG80ybhjxJDM1aLyS6_ocXKYBmcB9av0IY-saDVR7hkVNjc-iR9lhYI1682VbDmlQ9-uueCkK4YsqmO1mOSgYcQ-Hm56zQxhGrMHbGokIX667-8yHRbxjoag7eNxHrY5VQI-te17pDKE9G9cz87qvGSMPUi9QGdyt7a1652KWPXe6bDEOjIoaHUq9juOd7r8SxYCv8tqhAx5nxhqkaq9oHSfiYrrcddaSdtdCOYo7hyqVQV1562x0NZiNrGH9sU5V-e_5DAmPVqBMA1yjY3ZQEWWyTdQ_Wtw5qbs3m5qh5Grut8RtIb7yGJJsamDN3LG73jcrtXZ1cMynqN3LysksG8Y73RfO3joVhy3gw5Y1X6ES1gvQi4n1hxvOCCXoGIbIJwIZGjTlcuh2J_eweLo0hm1IeXK_lAB9P1RiruKfEc0P75CY4V_LDziEdFHxIpFS6PjH94n1aAj0F3ba6opqyjgXs6n8uuhoJvdq5GwnAuwsOzs771p8mWl0"},{"header":{"kid":"webstore"},"protected":"eyJhbGciOiJSUzI1NiJ9","signature":"fLFplPrKe8OWo-G7YhCQxsnj2MHPUqYvWL9ACSCD1WuA4K5c0pOFNMZ10w0Po0lgprE7LTCjWTk3pKKvyTxojWAyAg-c75DU2kfnntDabBEn9ooCiBcWJIuOkJMdcLYBbfe-t-JO0KPKm-2mGi59MkO9xir2MMwAqtITGdrH4WXjHTIB6guYQMtre_Bp_zqvZnGQKqZI0Cdq8QVqd7z69_j63fvv0CjXuZ-6F1RNElS75H3FzJ1OrVMCOjEOaKyk1DD-aqgr-6lUq2er1XWrf9JxtAmAawpnh3RAEi_1VoGtbga92USt_0ZLiapoC4PlWSloLuX-_NYFg9gtPJNS9w"}]}}] \ No newline at end of file diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-af.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-af.hyb new file mode 100644 index 000000000..54e6c0e14 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-af.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-as.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-as.hyb new file mode 100644 index 000000000..43a9527fa Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-as.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-be.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-be.hyb new file mode 100644 index 000000000..4da6b7497 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-be.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-bg.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-bg.hyb new file mode 100644 index 000000000..3f46fa1c9 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-bg.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-bn.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-bn.hyb new file mode 100644 index 000000000..43a9527fa Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-bn.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-cs.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-cs.hyb new file mode 100644 index 000000000..4255d5692 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-cs.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-cu.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-cu.hyb new file mode 100644 index 000000000..4ec90d398 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-cu.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-cy.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-cy.hyb new file mode 100644 index 000000000..5afe8aaae Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-cy.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-da.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-da.hyb new file mode 100644 index 000000000..f33f4307a Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-da.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-de-1901.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-de-1901.hyb new file mode 100644 index 000000000..7de89ade4 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-de-1901.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-de-1996.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-de-1996.hyb new file mode 100644 index 000000000..9880a9c3c Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-de-1996.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-de-ch-1901.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-de-ch-1901.hyb new file mode 100644 index 000000000..7e0b36acf Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-de-ch-1901.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-el.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-el.hyb new file mode 100644 index 000000000..413defded Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-el.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-en-gb.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-en-gb.hyb new file mode 100644 index 000000000..8b2ca3395 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-en-gb.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-en-us.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-en-us.hyb new file mode 100644 index 000000000..db1469a54 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-en-us.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-es.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-es.hyb new file mode 100644 index 000000000..1ef23304b Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-es.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-et.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-et.hyb new file mode 100644 index 000000000..bc42bf3af Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-et.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-eu.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-eu.hyb new file mode 100644 index 000000000..b9d6f468f Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-eu.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-fr.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-fr.hyb new file mode 100644 index 000000000..b24b5a2a3 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-fr.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-ga.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-ga.hyb new file mode 100644 index 000000000..3eb376f8d Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-ga.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-gl.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-gl.hyb new file mode 100644 index 000000000..604c80ae3 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-gl.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-gu.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-gu.hyb new file mode 100644 index 000000000..908ea1ac1 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-gu.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-hi.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-hi.hyb new file mode 100644 index 000000000..b0b9680f7 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-hi.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-hr.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-hr.hyb new file mode 100644 index 000000000..f73854cfe Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-hr.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-hu.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-hu.hyb new file mode 100644 index 000000000..95d819411 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-hu.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-hy.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-hy.hyb new file mode 100644 index 000000000..1bb183289 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-hy.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-it.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-it.hyb new file mode 100644 index 000000000..aadffdf66 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-it.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-ka.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-ka.hyb new file mode 100644 index 000000000..818a72d21 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-ka.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-kn.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-kn.hyb new file mode 100644 index 000000000..46bdbcf4c Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-kn.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-la.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-la.hyb new file mode 100644 index 000000000..c91ca2ffb Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-la.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-lt.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-lt.hyb new file mode 100644 index 000000000..98c190c3c Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-lt.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-lv.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-lv.hyb new file mode 100644 index 000000000..105c27440 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-lv.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-ml.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-ml.hyb new file mode 100644 index 000000000..c716ff2bd Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-ml.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-mn-cyrl.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-mn-cyrl.hyb new file mode 100644 index 000000000..3c6a4a4d5 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-mn-cyrl.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-mr.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-mr.hyb new file mode 100644 index 000000000..b0b9680f7 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-mr.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-mul-ethi.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-mul-ethi.hyb new file mode 100644 index 000000000..1bfa7d93c Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-mul-ethi.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-nb.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-nb.hyb new file mode 100644 index 000000000..1e897a025 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-nb.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-nl.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-nl.hyb new file mode 100644 index 000000000..09b81c57c Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-nl.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-nn.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-nn.hyb new file mode 100644 index 000000000..74cf56e49 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-nn.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-or.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-or.hyb new file mode 100644 index 000000000..e320ce8c5 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-or.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-pa.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-pa.hyb new file mode 100644 index 000000000..fd6132596 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-pa.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-pt.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-pt.hyb new file mode 100644 index 000000000..10a669be5 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-pt.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-ru.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-ru.hyb new file mode 100644 index 000000000..eddd313a0 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-ru.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-sk.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-sk.hyb new file mode 100644 index 000000000..303df318d Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-sk.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-sl.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-sl.hyb new file mode 100644 index 000000000..2215e70ab Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-sl.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-sq.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-sq.hyb new file mode 100644 index 000000000..dfb9c8b7f Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-sq.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-sv.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-sv.hyb new file mode 100644 index 000000000..9f07d78bf Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-sv.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-ta.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-ta.hyb new file mode 100644 index 000000000..3cb21b5be Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-ta.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-te.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-te.hyb new file mode 100644 index 000000000..4b3490711 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-te.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-tk.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-tk.hyb new file mode 100644 index 000000000..1bc934528 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-tk.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-uk.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-uk.hyb new file mode 100644 index 000000000..fc65a25e4 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-uk.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/hyph-und-ethi.hyb b/build/chromium-hyphen-data/120.0.6050.0/hyph-und-ethi.hyb new file mode 100644 index 000000000..3c98edbd9 Binary files /dev/null and b/build/chromium-hyphen-data/120.0.6050.0/hyph-und-ethi.hyb differ diff --git a/build/chromium-hyphen-data/120.0.6050.0/manifest.json b/build/chromium-hyphen-data/120.0.6050.0/manifest.json new file mode 100644 index 000000000..e8922aa4d --- /dev/null +++ b/build/chromium-hyphen-data/120.0.6050.0/manifest.json @@ -0,0 +1,5 @@ +{ + "manifest_version": 2, + "name": "hyphens-data", + "version": "120.0.6050.0" +} \ No newline at end of file diff --git a/cmd/gotenberg.go b/cmd/gotenberg.go index 7c3d37da7..c7bc3a13b 100644 --- a/cmd/gotenberg.go +++ b/cmd/gotenberg.go @@ -2,9 +2,11 @@ package gotenbergcmd import ( "context" + "errors" "fmt" "os" "os/signal" + "strings" "syscall" "time" @@ -23,7 +25,7 @@ const banner = ` \___/\___/\__/\__/_//_/_.__/\__/_/ \_, / /___/ -A Docker-powered stateless API for PDF files. +A containerized API for seamless PDF conversion. Version: %s ------------------------------------------------------- ` @@ -34,12 +36,13 @@ var Version = "snapshot" // Run starts the Gotenberg application. Call this in the main of your program. func Run() { - fmt.Printf(banner, Version) gotenberg.Version = Version // Create the root FlagSet and adds the modules flags to it. fs := flag.NewFlagSet("gotenberg", flag.ExitOnError) + fs.Bool("gotenberg-hide-banner", false, "Hide the banner") fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration") + fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed") descriptors := gotenberg.GetModuleDescriptors() var modsInfo string @@ -48,21 +51,51 @@ func Run() { modsInfo += desc.ID + " " } - fmt.Printf("[SYSTEM] modules: %s\n", modsInfo) - - // Parse the flags... + // Parse the flags. err := fs.Parse(os.Args[1:]) if err != nil { fmt.Println(err) os.Exit(1) } - // ...and create a wrapper around those. - parsedFlags := gotenberg.ParsedFlags{FlagSet: fs} + // 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) + } + }) - // Get the graceful shutdown duration. + // Create a wrapper around our flags. + parsedFlags := gotenberg.ParsedFlags{FlagSet: fs} + hideBanner := parsedFlags.MustBool("gotenberg-hide-banner") gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration") + if !hideBanner { + fmt.Printf(banner, Version) + } + fmt.Printf("[SYSTEM] modules: %s\n", modsInfo) + ctx := gotenberg.NewContext(parsedFlags, descriptors) // Start application modules. @@ -108,6 +141,11 @@ func Run() { }(l.(gotenberg.SystemLogger)) } + if parsedFlags.MustBool("gotenberg-build-debug-data") { + // Build the debug data. + gotenberg.BuildDebug(ctx) + } + quit := make(chan os.Signal, 1) // We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) or SIGTERM (Kubernetes). @@ -138,7 +176,9 @@ func Run() { id := app.(gotenberg.Module).Descriptor().ID err = app.Stop(gracefulShutdownCtx) - if err != nil { + if errors.Is(err, gotenberg.ErrCancelGracefulShutdownContext) { + cancel() + } else if err != nil { return fmt.Errorf("stopping %s: %w", id, err) } diff --git a/go.mod b/go.mod index 02b01da49..1959de790 100644 --- a/go.mod +++ b/go.mod @@ -1,63 +1,133 @@ module github.com/gotenberg/gotenberg/v8 -go 1.23.0 +go 1.25.4 require ( - github.com/alexliesenfeld/health v0.8.0 - github.com/andybalholm/brotli v1.1.1 // indirect + github.com/alexliesenfeld/health v0.8.1 github.com/barasher/go-exiftool v1.10.0 - github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b - github.com/chromedp/chromedp v0.11.2 - github.com/golang/snappy v0.0.4 // indirect + github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d + github.com/chromedp/chromedp v0.14.2 + github.com/cucumber/godog v0.15.1 + github.com/dlclark/regexp2 v1.11.5 + github.com/docker/docker v28.5.2+incompatible + github.com/docker/go-connections v0.6.0 + github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a github.com/google/uuid v1.6.0 - github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-retryablehttp v0.7.7 - github.com/klauspost/compress v1.17.11 // indirect - github.com/klauspost/pgzip v1.2.6 // indirect - github.com/labstack/echo/v4 v4.12.0 + github.com/hashicorp/go-retryablehttp v0.7.8 + github.com/labstack/echo/v4 v4.13.4 github.com/labstack/gommon v0.4.2 - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mholt/archiver/v3 v3.5.1 + github.com/mholt/archives v0.1.5 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/nwaples/rardecode v1.1.3 // indirect - github.com/pierrec/lz4/v4 v4.1.21 // indirect - github.com/prometheus/client_golang v1.20.5 - github.com/russross/blackfriday/v2 v2.1.0 - github.com/spf13/pflag v1.0.5 - github.com/ulikunitz/xz v0.5.12 // indirect + github.com/pdfcpu/pdfcpu v0.11.1 + github.com/prometheus/client_golang v1.23.2 + github.com/shirou/gopsutil/v4 v4.25.11 + github.com/spf13/pflag v1.0.10 + github.com/testcontainers/testcontainers-go v0.40.0 go.uber.org/multierr v1.11.0 - go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.29.0 // indirect - golang.org/x/net v0.31.0 - golang.org/x/sync v0.9.0 - golang.org/x/sys v0.27.0 // indirect - golang.org/x/term v0.26.0 - golang.org/x/text v0.20.0 + go.uber.org/zap v1.27.1 + golang.org/x/net v0.47.0 + golang.org/x/sync v0.18.0 + golang.org/x/term v0.37.0 + golang.org/x/text v0.31.0 ) -require github.com/dlclark/regexp2 v1.11.4 - require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/STARRY-S/zip v0.2.3 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bodgit/plumbing v1.3.0 // indirect + github.com/bodgit/sevenzip v1.6.1 // indirect + github.com/bodgit/windows v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect - github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect + github.com/cucumber/messages/go/v21 v21.0.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect + github.com/ebitengine/purego v0.9.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect - github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-memdb v1.3.5 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hhrutter/lzw v1.0.0 // indirect + github.com/hhrutter/pkcs7 v0.2.0 // indirect + github.com/hhrutter/tiff v1.0.2 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/pgzip v1.2.6 // indirect + github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mikelolasagasti/xz v1.0.1 // indirect + github.com/minio/minlz v1.0.1 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.60.1 // indirect - github.com/prometheus/procfs v0.15.1 // indirect + github.com/nwaples/rardecode/v2 v2.2.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sorairolake/lzip-go v0.3.8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect - golang.org/x/time v0.8.0 // indirect - google.golang.org/protobuf v1.35.2 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go4.org v0.0.0-20230225012048-214862532bf5 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/image v0.32.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6f328e5f4..0d4b4140c 100644 --- a/go.sum +++ b/go.sum @@ -1,151 +1,564 @@ -github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk= -github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc= -github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= -github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= -github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= +github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= +github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg= +github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs= github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= +github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= +github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= +github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= +github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= +github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b h1:md1Gk5jkNE91SZxFDCMHmKqX0/GsEr1/VTejht0sCbY= -github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM= -github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0= -github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8= +github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= +github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= +github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= +github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= +github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= +github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI= +github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8= +github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI= +github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s= +github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= -github.com/dlclark/regexp2 v1.11.4/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.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= +github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/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/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-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-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= 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-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A= +github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= -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/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg= +github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo= +github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I= +github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= +github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8= +github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= -github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= -github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= +github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= -github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= +github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= +github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= +github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= +github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= +github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= -github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= -github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= +github.com/nwaples/rardecode/v2 v2.2.1 h1:DgHK/O/fkTQEKBJxBMC5d9IU8IgauifbpG78+rZJMnI= +github.com/nwaples/rardecode/v2 v2.2.1/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= -github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= -github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas= +github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= -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/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc= -github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= -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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= +github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY= +github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= +github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/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/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= -github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= -golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= -golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= +go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +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/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= +golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= +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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= -golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= -golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +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.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +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= -google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= -google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8= +google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/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.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..b68f49314 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,154 @@ +{ + "name": "gotenberg", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "prettier": "3.7.4", + "prettier-plugin-gherkin": "^3.1.3", + "prettier-plugin-sh": "^0.18.0" + } + }, + "node_modules/@cucumber/gherkin": { + "version": "32.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-32.2.0.tgz", + "integrity": "sha512-X8xuVhSIqlUjxSRifRJ7t0TycVWyX58fygJH3wDNmHINLg9sYEkvQT0SO2G5YlRZnYc11TIFr4YPenscvdlBIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/messages": ">=19.1.4 <28" + } + }, + "node_modules/@cucumber/messages": { + "version": "27.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz", + "integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/uuid": "10.0.0", + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2", + "uuid": "11.0.5" + } + }, + "node_modules/@reteps/dockerfmt": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.3.6.tgz", + "integrity": "sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^v12.20.0 || ^14.13.0 || >=16.0.0" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-gherkin": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/prettier-plugin-gherkin/-/prettier-plugin-gherkin-3.1.3.tgz", + "integrity": "sha512-w9uB413NlSi8ZQwpexyu+ttriJJ88eZLV0x88ZTkzkLZyHYEX5wrNtaCx/yFYviIu/tuwsBqDPM47VODnIV/hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/gherkin": "^32.0.0", + "@cucumber/messages": "^27.2.0", + "prettier": "^3.5.3" + } + }, + "node_modules/prettier-plugin-sh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.0.tgz", + "integrity": "sha512-cW1XL27FOJQ/qGHOW6IHwdCiNWQsAgK+feA8V6+xUTaH0cD3Mh+tFAtBvEEWvuY6hTDzRV943Fzeii+qMOh7nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@reteps/dockerfmt": "^0.3.6", + "sh-syntax": "^0.5.8" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + }, + "peerDependencies": { + "prettier": "^3.6.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/sh-syntax": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.5.8.tgz", + "integrity": "sha512-JfVoxf4FxQI5qpsPbkHhZo+n6N9YMJobyl4oGEUBb/31oQYlgTjkXQD8PBiafS2UbWoxrTO0Z5PJUBXEPAG1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/sh-syntax" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..7fed1354d --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "devDependencies": { + "prettier": "3.7.4", + "prettier-plugin-gherkin": "^3.1.3", + "prettier-plugin-sh": "^0.18.0" + } +} diff --git a/pkg/gotenberg/cmd.go b/pkg/gotenberg/cmd.go index afbb0f713..83dd3ba22 100644 --- a/pkg/gotenberg/cmd.go +++ b/pkg/gotenberg/cmd.go @@ -74,7 +74,7 @@ func (cmd *Cmd) Start() error { } // Wait waits for the command to complete. It should be called when using the -// Start method, so that the command does not leak zombies. +// Start method so that the command does not leak zombies. func (cmd *Cmd) Wait() error { err := cmd.process.Wait() if err != nil { @@ -84,7 +84,7 @@ func (cmd *Cmd) Wait() error { return nil } -// Exec executes the command and wait for its completion or until the context +// Exec executes the command and waits for its completion or until the context // is done. In any case, it kills the unix process and all its children. func (cmd *Cmd) Exec() (int, error) { if cmd.ctx == nil { diff --git a/pkg/gotenberg/cmd_test.go b/pkg/gotenberg/cmd_test.go deleted file mode 100644 index 1687387ca..000000000 --- a/pkg/gotenberg/cmd_test.go +++ /dev/null @@ -1,306 +0,0 @@ -package gotenberg - -import ( - "context" - "testing" - "time" - - "go.uber.org/zap" -) - -func TestCommand(t *testing.T) { - cmd := Command(zap.NewNop(), "foo") - if !cmd.process.SysProcAttr.Setpgid { - t.Error("expected cmd.process.SysProcAttr.Setpgid to be true") - } -} - -func TestCommandContext(t *testing.T) { - tests := []struct { - scenario string - ctx context.Context - expectCommandContextError bool - }{ - { - scenario: "nominal behavior", - ctx: context.Background(), - expectCommandContextError: false, - }, - { - scenario: "nil context", - ctx: nil, - expectCommandContextError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.scenario, func(t *testing.T) { - cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo") - - if err == nil && !cmd.process.SysProcAttr.Setpgid { - t.Fatal("expected cmd.process.SysProcAttr.Setpgid to be true") - } - - if !tc.expectCommandContextError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectCommandContextError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestCmd_Start(t *testing.T) { - tests := []struct { - scenario string - cmd *Cmd - expectStartError bool - }{ - { - scenario: "nominal behavior", - cmd: Command(zap.NewNop(), "echo", "Hello", "World"), - expectStartError: false, - }, - { - scenario: "start error", - cmd: Command(zap.NewNop(), "foo"), - expectStartError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.scenario, func(t *testing.T) { - err := tc.cmd.Start() - - if !tc.expectStartError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectStartError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestCmd_Wait(t *testing.T) { - tests := []struct { - scenario string - cmd *Cmd - expectWaitError bool - }{ - { - scenario: "nominal behavior", - cmd: func() *Cmd { - cmd := Command(zap.NewNop(), "echo", "Hello", "World") - err := cmd.Start() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - return cmd - }(), - expectWaitError: false, - }, - { - scenario: "wait error", - cmd: Command(zap.NewNop(), "echo", "Hello", "World"), - expectWaitError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.scenario, func(t *testing.T) { - err := tc.cmd.Wait() - - if !tc.expectWaitError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectWaitError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestCmd_Exec(t *testing.T) { - tests := []struct { - scenario string - cmd *Cmd - timeout time.Duration - expectExecError bool - }{ - { - scenario: "nominal behavior", - cmd: func() *Cmd { - cmd, err := CommandContext(context.Background(), zap.NewNop(), "echo", "Hello", "World") - if err != nil { - t.Fatalf("expected no error from CommandContext(), but got: %v", err) - } - return cmd - }(), - expectExecError: false, - }, - { - scenario: "nil context", - cmd: Command(zap.NewNop(), "echo", "Hello", "World"), - expectExecError: true, - }, - { - scenario: "start error", - cmd: func() *Cmd { - cmd, err := CommandContext(context.Background(), zap.NewNop(), "foo") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - return cmd - }(), - expectExecError: true, - }, - { - scenario: "context done", - cmd: Command(zap.NewNop(), "sleep", "2"), - timeout: time.Duration(1) * time.Second, - expectExecError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.scenario, func(t *testing.T) { - if tc.timeout > 0 { - ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout) - defer cancel() - - tc.cmd.ctx = ctx - } - - _, err := tc.cmd.Exec() - - if !tc.expectExecError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectExecError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestCmd_pipeOutput(t *testing.T) { - tests := []struct { - scenario string - cmd *Cmd - run bool - expectPipeOutputError bool - }{ - { - scenario: "nominal behavior", - cmd: Command(zap.NewExample(), "echo", "Hello", "World"), - run: true, - expectPipeOutputError: false, - }, - { - scenario: "no debug, no pipe", - cmd: Command(zap.NewNop(), "echo", "Hello", "World"), - run: false, - expectPipeOutputError: false, - }, - { - scenario: "stdout already piped", - cmd: func() *Cmd { - cmd := Command(zap.NewExample(), "echo", "Hello", "World") - _, err := cmd.process.StdoutPipe() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - return cmd - }(), - run: false, - expectPipeOutputError: true, - }, - { - scenario: "stderr already piped", - cmd: func() *Cmd { - cmd := Command(zap.NewExample(), "echo", "Hello", "World") - _, err := cmd.process.StderrPipe() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - return cmd - }(), - run: false, - expectPipeOutputError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.scenario, func(t *testing.T) { - err := tc.cmd.pipeOutput() - - if tc.run { - errStart := tc.cmd.process.Start() - if errStart != nil { - t.Fatalf("expected no error but got: %v", err) - } - } - - if !tc.expectPipeOutputError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectPipeOutputError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestCmd_Kill(t *testing.T) { - tests := []struct { - scenario string - cmd *Cmd - }{ - { - scenario: "nominal behavior", - cmd: func() *Cmd { - cmd := Command(zap.NewNop(), "sleep", "60") - err := cmd.process.Start() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - return cmd - }(), - }, - { - scenario: "no process", - cmd: &Cmd{logger: zap.NewNop()}, - }, - { - scenario: "process already killed", - cmd: func() *Cmd { - cmd := Command(zap.NewNop(), "sleep", "60") - err := cmd.process.Start() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - err = cmd.Kill() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - return cmd - }(), - }, - } - - for _, tc := range tests { - t.Run(tc.scenario, func(t *testing.T) { - err := tc.cmd.Kill() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }) - } -} diff --git a/pkg/gotenberg/context.go b/pkg/gotenberg/context.go index 48ab7f5d0..507f30d4e 100644 --- a/pkg/gotenberg/context.go +++ b/pkg/gotenberg/context.go @@ -5,7 +5,7 @@ import ( "reflect" ) -// Context is a struct which helps to initialize modules. When provisioning, a +// Context is a struct that helps to initialize modules. When provisioning, a // module may use the context to get other modules that it needs internally. type Context struct { flags ParsedFlags @@ -58,7 +58,7 @@ func (ctx *Context) Module(kind interface{}) (interface{}, error) { return mods[0], nil } -// Modules returns the list of modules which satisfies the requested interface. +// Modules return the list of modules which satisfies the requested interface. // // func (m *YourModule) Provision(ctx *gotenberg.Context) error { // mods, _ := ctx.Modules(new(ModuleInterface)) diff --git a/pkg/gotenberg/context_test.go b/pkg/gotenberg/context_test.go index 9b48cfd22..f8bc4d6f7 100644 --- a/pkg/gotenberg/context_test.go +++ b/pkg/gotenberg/context_test.go @@ -5,23 +5,6 @@ import ( "testing" ) -func TestNewContext(t *testing.T) { - if NewContext(ParsedFlags{}, nil) == nil { - t.Error("expected a non-nil value") - } -} - -func TestContext_ParsedFlags(t *testing.T) { - ctx := NewContext(ParsedFlags{}, nil) - - actual := ctx.ParsedFlags() - expect := ParsedFlags{} - - if actual != expect { - t.Errorf("expected %v but got %v", expect, actual) - } -} - func TestContext_Module(t *testing.T) { for _, tc := range []struct { scenario string diff --git a/pkg/gotenberg/debug.go b/pkg/gotenberg/debug.go new file mode 100644 index 000000000..9a83a6991 --- /dev/null +++ b/pkg/gotenberg/debug.go @@ -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 +) diff --git a/pkg/gotenberg/debug_test.go b/pkg/gotenberg/debug_test.go new file mode 100644 index 000000000..5c50fde5d --- /dev/null +++ b/pkg/gotenberg/debug_test.go @@ -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()) + } +} diff --git a/pkg/gotenberg/fs.go b/pkg/gotenberg/fs.go index 8c2d0a98c..5ddc997f9 100644 --- a/pkg/gotenberg/fs.go +++ b/pkg/gotenberg/fs.go @@ -7,18 +7,50 @@ import ( "github.com/google/uuid" ) +// MkdirAll defines the method signature for create a directory. Implement this +// interface if you don't want to rely on [os.MkdirAll], notably for testing +// purpose. +type MkdirAll interface { + // MkdirAll uses the same signature as [os.MkdirAll]. + MkdirAll(path string, perm os.FileMode) error +} + +// OsMkdirAll implements the [MkdirAll] interface with [os.MkdirAll]. +type OsMkdirAll struct{} + +// MkdirAll is a wrapper around [os.MkdirAll]. +func (o *OsMkdirAll) MkdirAll(path string, perm os.FileMode) error { return os.MkdirAll(path, perm) } + +// PathRename defines the method signature for renaming files. Implement this +// interface if you don't want to rely on [os.Rename], notably for testing +// purposes. +type PathRename interface { + // Rename uses the same signature as [os.Rename]. + Rename(oldpath, newpath string) error +} + +// OsPathRename implements the [PathRename] interface with [os.Rename]. +type OsPathRename struct{} + +// Rename is a wrapper around [os.Rename]. +func (o *OsPathRename) Rename(oldpath, newpath string) error { + return os.Rename(oldpath, newpath) +} + // FileSystem provides utilities for managing temporary directories. It creates // unique directory names based on UUIDs to ensure isolation of temporary files // for different modules. type FileSystem struct { workingDir string + mkdirAll MkdirAll } // NewFileSystem initializes a new [FileSystem] instance with a unique working // directory. -func NewFileSystem() *FileSystem { +func NewFileSystem(mkdirAll MkdirAll) *FileSystem { return &FileSystem{ workingDir: uuid.NewString(), + mkdirAll: mkdirAll, } } @@ -44,7 +76,7 @@ func (fs *FileSystem) NewDirPath() string { func (fs *FileSystem) MkdirAll() (string, error) { path := fs.NewDirPath() - err := os.MkdirAll(path, 0o755) + err := fs.mkdirAll.MkdirAll(path, 0o755) if err != nil { return "", fmt.Errorf("create directory %s: %w", path, err) } @@ -52,10 +84,8 @@ func (fs *FileSystem) MkdirAll() (string, error) { return path, nil } -// 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 -} +// Interface guards. +var ( + _ MkdirAll = (*OsMkdirAll)(nil) + _ PathRename = (*OsPathRename)(nil) +) diff --git a/pkg/gotenberg/fs_test.go b/pkg/gotenberg/fs_test.go deleted file mode 100644 index f074acb66..000000000 --- a/pkg/gotenberg/fs_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package gotenberg - -import ( - "fmt" - "os" - "strings" - "testing" -) - -func TestFileSystem_WorkingDir(t *testing.T) { - fs := NewFileSystem() - dirName := fs.WorkingDir() - - if dirName == "" { - t.Error("expected directory name but got empty string") - } -} - -func TestFileSystem_WorkingDirPath(t *testing.T) { - fs := NewFileSystem() - expectedPath := fmt.Sprintf("%s/%s", os.TempDir(), fs.WorkingDir()) - - if fs.WorkingDirPath() != expectedPath { - t.Errorf("expected path '%s' but got '%s'", expectedPath, fs.WorkingDirPath()) - } -} - -func TestFileSystem_NewDirPath(t *testing.T) { - fs := NewFileSystem() - newDir := fs.NewDirPath() - expectedPrefix := fs.WorkingDirPath() - - if !strings.HasPrefix(newDir, expectedPrefix) { - t.Errorf("expected new directory to start with '%s' but got '%s'", expectedPrefix, newDir) - } -} - -func TestFileSystem_MkdirAll(t *testing.T) { - fs := NewFileSystem() - - newPath, err := fs.MkdirAll() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - _, err = os.Stat(newPath) - if os.IsNotExist(err) { - t.Errorf("expected directory '%s' to exist but it doesn't", newPath) - } - - err = os.RemoveAll(fs.WorkingDirPath()) - if err != nil { - t.Fatalf("expected no error while cleaning up but got: %v", err) - } -} diff --git a/pkg/gotenberg/gc.go b/pkg/gotenberg/gc.go index 3de80a4cc..169dd508e 100644 --- a/pkg/gotenberg/gc.go +++ b/pkg/gotenberg/gc.go @@ -5,13 +5,14 @@ import ( "os" "path/filepath" "strings" + "time" "go.uber.org/zap" ) // GarbageCollect scans the root path and deletes files or directories with -// names containing specific substrings. -func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string) error { +// names containing specific substrings and before a given expiration time. +func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error { logger = logger.Named("gc") // To make sure that the next Walk method stays on @@ -36,7 +37,7 @@ func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string) } for _, substr := range includeSubstr { - if strings.Contains(info.Name(), substr) || path == substr { + if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) { err := os.RemoveAll(path) if err != nil { return fmt.Errorf("garbage collect '%s': %w", path, err) diff --git a/pkg/gotenberg/gc_test.go b/pkg/gotenberg/gc_test.go index 4dd58e4fb..833a0a6f6 100644 --- a/pkg/gotenberg/gc_test.go +++ b/pkg/gotenberg/gc_test.go @@ -3,7 +3,9 @@ package gotenberg import ( "fmt" "os" + "path" "testing" + "time" "github.com/google/uuid" "go.uber.org/zap" @@ -26,31 +28,31 @@ func TestGarbageCollect(t *testing.T) { { scenario: "remove include substrings", rootPath: func() string { - path := fmt.Sprintf("%s/a_directory", os.TempDir()) + p := fmt.Sprintf("%s/a_directory", os.TempDir()) - err := os.MkdirAll(path, 0o755) + err := os.MkdirAll(p, 0o755) 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", p), []byte{1}, 0o755) if err != nil { t.Fatalf("expected no error but got: %v", err) } - err = os.WriteFile(fmt.Sprintf("%s/a_bar_file", path), []byte{1}, 0o755) + err = os.WriteFile(fmt.Sprintf("%s/a_bar_file", p), []byte{1}, 0o755) if err != nil { t.Fatalf("expected no error but got: %v", err) } - err = os.WriteFile(fmt.Sprintf("%s/a_baz_file", path), []byte{1}, 0o755) + err = os.WriteFile(fmt.Sprintf("%s/a_baz_file", p), []byte{1}, 0o755) if err != nil { t.Fatalf("expected no error but got: %v", err) } - return path + return p }(), - includeSubstr: []string{"foo", fmt.Sprintf("%s/a_directory/a_bar_file", os.TempDir())}, + includeSubstr: []string{"foo", path.Join(os.TempDir(), "/a_directory/a_bar_file")}, expectError: false, expectExists: []string{"a_baz_file"}, expectNotExists: []string{"a_foo_file", "a_bar_file"}, @@ -64,7 +66,7 @@ func TestGarbageCollect(t *testing.T) { } }() - err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr) + err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr, time.Now()) if !tc.expectError && err != nil { t.Fatalf("expected no error but got: %v", err) @@ -79,18 +81,18 @@ func TestGarbageCollect(t *testing.T) { } for _, name := range tc.expectNotExists { - path := fmt.Sprintf("%s/%s", tc.rootPath, name) - _, err = os.Stat(path) + p := fmt.Sprintf("%s/%s", tc.rootPath, name) + _, err = os.Stat(p) if !os.IsNotExist(err) { - t.Errorf("expected '%s' not to exist but it does: %v", path, err) + t.Errorf("expected '%s' not to exist but it does: %v", p, err) } } for _, name := range tc.expectExists { - path := fmt.Sprintf("%s/%s", tc.rootPath, name) - _, err = os.Stat(path) + p := fmt.Sprintf("%s/%s", tc.rootPath, name) + _, err = os.Stat(p) if os.IsNotExist(err) { - t.Errorf("expected '%s' to exist but it does not: %v", path, err) + t.Errorf("expected '%s' to exist but it does not: %v", p, err) } } }) diff --git a/pkg/gotenberg/logging.go b/pkg/gotenberg/logging.go index 1fb116453..ea0bdf425 100644 --- a/pkg/gotenberg/logging.go +++ b/pkg/gotenberg/logging.go @@ -18,7 +18,7 @@ type LoggerProvider interface { Logger(mod Module) (*zap.Logger, error) } -// LeveledLogger is wrapper around a [zap.Logger] so that it may be used by a +// LeveledLogger is a wrapper around a [zap.Logger] so that it may be used by a // [retryablehttp.Client]. type LeveledLogger struct { logger *zap.Logger @@ -31,22 +31,22 @@ func NewLeveledLogger(logger *zap.Logger) *LeveledLogger { } } -// Error logs a message at error level using the wrapped zap.Logger. +// Error logs a message at the error level using the wrapped zap.Logger. func (leveled LeveledLogger) Error(msg string, keysAndValues ...interface{}) { leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues)) } -// Warn logs a message at warning level using the wrapped zap.Logger. +// Warn logs a message at the warning level using the wrapped zap.Logger. func (leveled LeveledLogger) Warn(msg string, keysAndValues ...interface{}) { leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues)) } -// Info logs a message at info level using the wrapped zap.Logger. +// Info logs a message at the info level using the wrapped zap.Logger. func (leveled LeveledLogger) Info(msg string, keysAndValues ...interface{}) { leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues)) } -// Debug logs a message at debug level using the wrapped zap.Logger. +// Debug logs a message at the debug level using the wrapped zap.Logger. func (leveled LeveledLogger) Debug(msg string, keysAndValues ...interface{}) { leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues)) } diff --git a/pkg/gotenberg/logging_test.go b/pkg/gotenberg/logging_test.go deleted file mode 100644 index cc8f203d1..000000000 --- a/pkg/gotenberg/logging_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package gotenberg - -import ( - "testing" - - "go.uber.org/zap" -) - -func TestLeveledLogger_Error(t *testing.T) { - NewLeveledLogger(zap.NewNop()).Error("foo") -} - -func TestLeveledLogger_Warn(t *testing.T) { - NewLeveledLogger(zap.NewNop()).Warn("foo") -} - -func TestLeveledLogger_Info(t *testing.T) { - NewLeveledLogger(zap.NewNop()).Info("foo") -} - -func TestLeveledLogger_Debug(t *testing.T) { - NewLeveledLogger(zap.NewNop()).Debug("foo") -} diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index 49154c32d..3e2236b75 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -2,6 +2,7 @@ package gotenberg import ( "context" + "os" "go.uber.org/zap" ) @@ -33,18 +34,41 @@ func (mod *ValidatorMock) Validate() error { return mod.ValidateMock() } +type DebuggableMock struct { + DebugMock func() map[string]interface{} +} + +func (mod *DebuggableMock) Debug() map[string]interface{} { + return mod.DebugMock() +} + // PdfEngineMock is a mock for the [PdfEngine] interface. +// +//nolint:dupl type PdfEngineMock struct { - MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error - ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error - 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 + MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error + SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) + FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error + ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error + ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) + WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error + EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error + EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error + ImportBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error } func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return engine.MergeMock(ctx, logger, inputPaths, outputPath) } +func (engine *PdfEngineMock) Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) { + return engine.SplitMock(ctx, logger, mode, inputPath, outputDirPath) +} + +func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { + return engine.FlattenMock(ctx, logger, inputPath) +} + func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error { return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath) } @@ -57,6 +81,18 @@ func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logg return engine.WriteMetadataMock(ctx, logger, metadata, inputPath) } +func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword) +} + +func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { + return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath) +} + +func (engine *PdfEngineMock) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error { + return engine.ImportBookmarksMock(ctx, logger, inputPath, inputBookmarksPath, outputPath) +} + // PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface. type PdfEngineProviderMock struct { PdfEngineMock func() (PdfEngine, error) @@ -137,6 +173,15 @@ func (provider *MetricsProviderMock) Metrics() ([]Metric, error) { return provider.MetricsMock() } +// MkdirAllMock is a mock for the [MkdirAll] interface. +type MkdirAllMock struct { + MkdirAllMock func(path string, perm os.FileMode) error +} + +func (mkdirAll *MkdirAllMock) MkdirAll(path string, perm os.FileMode) error { + return mkdirAll.MkdirAllMock(path, perm) +} + // PathRenameMock is a mock for the [PathRename] interface. type PathRenameMock struct { RenameMock func(oldpath, newpath string) error @@ -156,4 +201,6 @@ var ( _ ProcessSupervisor = (*ProcessSupervisorMock)(nil) _ LoggerProvider = (*LoggerProviderMock)(nil) _ MetricsProvider = (*MetricsProviderMock)(nil) + _ MkdirAll = (*MkdirAllMock)(nil) + _ PathRename = (*PathRenameMock)(nil) ) diff --git a/pkg/gotenberg/mocks_test.go b/pkg/gotenberg/mocks_test.go deleted file mode 100644 index 1be6c658c..000000000 --- a/pkg/gotenberg/mocks_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package gotenberg - -import ( - "context" - "testing" - - "go.uber.org/zap" -) - -func TestModuleMock(t *testing.T) { - mock := &ModuleMock{ - DescriptorMock: func() ModuleDescriptor { - return ModuleDescriptor{ID: "foo", New: func() Module { - return nil - }} - }, - } - - if mock.Descriptor().ID != "foo" { - t.Errorf("expected ID '%s' from ModuleMock.Descriptor, but got '%s'", "foo", mock.Descriptor().ID) - } -} - -func TestProvisionerMock(t *testing.T) { - mock := &ProvisionerMock{ - ProvisionMock: func(*Context) error { - return nil - }, - } - - err := mock.Provision(&Context{}) - if err != nil { - t.Errorf("expected no error from ProvisionerMock.Provision, but got: %v", err) - } -} - -func TestValidatorMock(t *testing.T) { - mock := &ValidatorMock{ - ValidateMock: func() error { - return nil - }, - } - - err := mock.Validate() - if err != nil { - t.Errorf("expected no error from ValidatorMock.Validate, but got: %v", err) - } -} - -func TestPDFEngineMock(t *testing.T) { - mock := &PdfEngineMock{ - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return nil - }, - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error { - return nil - }, - ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { - return nil, nil - }, - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { - return nil - }, - } - - err := mock.Merge(context.Background(), zap.NewNop(), nil, "") - if err != nil { - t.Errorf("expected no error from PdfEngineMock.Merge, but got: %v", err) - } - - err = mock.Convert(context.Background(), zap.NewNop(), PdfFormats{}, "", "") - if err != nil { - t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err) - } - - _, err = mock.ReadMetadata(context.Background(), zap.NewNop(), "") - if err != nil { - t.Errorf("expected no error from PdfEngineMock.ReadMetadata, but got: %v", err) - } - - err = mock.WriteMetadata(context.Background(), zap.NewNop(), map[string]interface{}{}, "") - if err != nil { - t.Errorf("expected no error from PdfEngineMock.WriteMetadata but got: %v", err) - } -} - -func TestPDFEngineProviderMock(t *testing.T) { - mock := &PdfEngineProviderMock{ - PdfEngineMock: func() (PdfEngine, error) { - return new(PdfEngineMock), nil - }, - } - - _, err := mock.PdfEngine() - if err != nil { - t.Errorf("expected no error from PdfEngineProviderMock.PdfEngine, but got: %v", err) - } -} - -func TestProcessMock(t *testing.T) { - mock := &ProcessMock{ - StartMock: func(logger *zap.Logger) error { - return nil - }, - StopMock: func(logger *zap.Logger) error { - return nil - }, - HealthyMock: func(logger *zap.Logger) bool { - return true - }, - } - - err := mock.Start(zap.NewNop()) - if err != nil { - t.Errorf("expected no error from ProcessMock.Start, but got: %v", err) - } - - err = mock.Stop(zap.NewNop()) - if err != nil { - t.Errorf("expected no error from ProcessMock.Stop, but got: %v", err) - } - - healthy := mock.Healthy(zap.NewNop()) - if !healthy { - t.Error("expected true from ProcessMock.Healthy, but got false") - } -} - -func TestProcessSupervisorMock(t *testing.T) { - mock := &ProcessSupervisorMock{ - LaunchMock: func() error { - return nil - }, - ShutdownMock: func() error { - return nil - }, - HealthyMock: func() bool { - return true - }, - RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error { - return nil - }, - ReqQueueSizeMock: func() int64 { - return 0 - }, - RestartsCountMock: func() int64 { - return 0 - }, - } - - err := mock.Launch() - if err != nil { - t.Errorf("expected no error from ProcessSupervisorMock.Launch, but got: %v", err) - } - - err = mock.Shutdown() - if err != nil { - t.Errorf("expected no error from ProcessSupervisorMock.Shutdown, but got: %v", err) - } - - healthy := mock.Healthy() - if !healthy { - t.Error("expected true from ProcessSupervisorMock.Healthy, but got false") - } - - err = mock.Run(context.TODO(), zap.NewNop(), nil) - if err != nil { - t.Errorf("expected no error from ProcessSupervisorMock.Run, but got: %v", err) - } - - size := mock.ReqQueueSize() - if size != 0 { - t.Errorf("expected 0 from ProcessSupervisorMock.ReqQueueSize, but got: %d", size) - } - - restarts := mock.RestartsCount() - if restarts != 0 { - t.Errorf("expected 0 from ProcessSupervisorMock.RestartsCount, but got: %d", restarts) - } -} - -func TestLoggerProviderMock(t *testing.T) { - mock := &LoggerProviderMock{ - LoggerMock: func(mod Module) (*zap.Logger, error) { - return nil, nil - }, - } - - _, err := mock.Logger(new(ModuleMock)) - if err != nil { - t.Errorf("expected no error from LoggerProviderMock.Logger, but got: %v", err) - } -} - -func TestMetricsProviderMock(t *testing.T) { - mock := &MetricsProviderMock{ - MetricsMock: func() ([]Metric, error) { - return nil, nil - }, - } - - _, err := mock.Metrics() - if err != nil { - t.Errorf("expected no error from MetricsProviderMock.Metrics, but got: %v", err) - } -} - -func TestPathRenameMock(t *testing.T) { - mock := &PathRenameMock{ - RenameMock: func(oldpath, newpath string) error { - return nil - }, - } - - err := mock.Rename("", "") - if err != nil { - t.Errorf("expected no error from PathRenameMock.Rename, but got: %v", err) - } -} diff --git a/pkg/gotenberg/modules.go b/pkg/gotenberg/modules.go index e6ee875ce..3df314595 100644 --- a/pkg/gotenberg/modules.go +++ b/pkg/gotenberg/modules.go @@ -75,6 +75,12 @@ type SystemLogger interface { SystemMessages() []string } +// Debuggable is a module interface for modules which want to provide +// additional debug data. +type Debuggable interface { + Debug() map[string]interface{} +} + // MustRegisterModule registers a module. // // To register a module, create an init() method in the module main go file: diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go index 87c32c158..27f6c1e20 100644 --- a/pkg/gotenberg/pdfengine.go +++ b/pkg/gotenberg/pdfengine.go @@ -3,6 +3,7 @@ package gotenberg import ( "context" "errors" + "fmt" "go.uber.org/zap" ) @@ -12,6 +13,10 @@ var ( // PdfEngine interface is not supported by its current implementation. ErrPdfEngineMethodNotSupported = errors.New("method not supported") + // ErrPdfSplitModeNotSupported is returned when the Split method of the + // PdfEngine interface does not support a requested PDF split mode. + ErrPdfSplitModeNotSupported = errors.New("split mode not supported") + // ErrPdfFormatNotSupported is returned when the Convert method of the // PdfEngine interface does not support a requested PDF format conversion. ErrPdfFormatNotSupported = errors.New("PDF format not supported") @@ -19,8 +24,55 @@ var ( // ErrPdfEngineMetadataValueNotSupported is returned when a metadata value // is not supported. ErrPdfEngineMetadataValueNotSupported = errors.New("metadata value not supported") + + // ErrPdfEncryptionNotSupported is returned when encryption + // is not supported by the PDF engine. + ErrPdfEncryptionNotSupported = errors.New("encryption not supported") +) + +// PdfEngineInvalidArgsError represents an error returned by a PDF engine when +// invalid arguments are provided. It includes the name of the engine and a +// detailed message describing the issue. +type PdfEngineInvalidArgsError struct { + engine string + msg string +} + +// Error implements the error interface. +func (e *PdfEngineInvalidArgsError) Error() string { + return fmt.Sprintf("%s: %s", e.engine, e.msg) +} + +// NewPdfEngineInvalidArgs creates a new PdfEngineInvalidArgsError with the +// given engine name and message. +func NewPdfEngineInvalidArgs(engine, msg string) error { + return &PdfEngineInvalidArgsError{engine, msg} +} + +const ( + // SplitModeIntervals represents a mode where a PDF is split at specific + // intervals. + SplitModeIntervals string = "intervals" + + // SplitModePages represents a mode where a PDF is split at specific page + // ranges. + SplitModePages string = "pages" ) +// SplitMode gathers the data required to split a PDF into multiple parts. +type SplitMode struct { + // Mode is either "intervals" or "pages". + Mode string + + // Span is either the intervals or the page ranges to extract, depending on + // the selected mode. + Span string + + // Unify specifies whether to put extracted pages into a single file or as + // many files as there are page ranges. Only works with "pages" mode. + Unify bool +} + const ( // PdfA1a represents the PDF/A-1a format. PdfA1a string = "PDF/A-1a" @@ -58,13 +110,24 @@ type PdfFormats struct { } // PdfEngine provides an interface for operations on PDFs. Implementations -// can utilize various tools like PDFtk, or implement functionality directly in +// can use various tools like PDFtk, or implement functionality directly in // Go. +// +//nolint:dupl type PdfEngine interface { // Merge combines multiple PDFs into a single PDF. The resulting page order // is determined by the order of files provided in inputPaths. Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error + // Split splits a given PDF file. + Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) + + // Flatten merges existing annotation appearances with page content, + // effectively deleting the original annotations. This process can flatten + // forms as well as forms share a relationship with annotations. Note that + // this operation is irreversible. + Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error + // Convert transforms a given PDF to the specified formats defined in // PdfFormats. If no format, it does nothing. Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error @@ -74,6 +137,19 @@ type PdfEngine interface { // WriteMetadata writes the metadata into a given PDF file. WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error + + // ImportBookmarks imports bookmarks from a JSON file into a given PDF. + ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error + + // Encrypt adds password protection to a PDF file. + // The userPassword is required to open the document. + // The ownerPassword provides full access to the document. + // If the ownerPassword is empty, it defaults to the userPassword. + Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error + + // EmbedFiles embeds files into a PDF. All files are embedded as file attachments + // without modifying the main PDF content. + EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error } // PdfEngineProvider offers an interface to instantiate a [PdfEngine]. diff --git a/pkg/gotenberg/shutdown.go b/pkg/gotenberg/shutdown.go new file mode 100644 index 000000000..053ed5d15 --- /dev/null +++ b/pkg/gotenberg/shutdown.go @@ -0,0 +1,8 @@ +package gotenberg + +import "errors" + +// ErrCancelGracefulShutdownContext tells that a module wants to abort a +// graceful shutdown and stops Gotenberg right away as there are no more +// ongoing processes. +var ErrCancelGracefulShutdownContext = errors.New("cancel graceful shutdown's context") diff --git a/pkg/gotenberg/sort.go b/pkg/gotenberg/sort.go index bd18da697..5ff878058 100644 --- a/pkg/gotenberg/sort.go +++ b/pkg/gotenberg/sort.go @@ -1,15 +1,28 @@ package gotenberg import ( + "path/filepath" "regexp" "sort" "strconv" ) +type numberLoc int + +const ( + numberNone numberLoc = iota + numberPrefix + numberExtSuffix // number right before extension. + numberSuffix // trailing number with no extension. +) + // 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. +// https://github.com/gotenberg/gotenberg/issues/1287. type AlphanumericSort []string func (s AlphanumericSort) Len() int { @@ -21,20 +34,39 @@ func (s AlphanumericSort) Swap(i, j int) { } func (s AlphanumericSort) Less(i, j int) bool { - numI, restI := extractPrefix(s[i]) - numJ, restJ := extractPrefix(s[j]) + numI, restI, locI := extractNumber(s[i]) + numJ, restJ, locJ := extractNumber(s[j]) - // Compares numerical prefixes if they exist. + // Both have a number. if numI != -1 && numJ != -1 { - if numI != numJ { - return numI < numJ + // Both prefix numbers: numeric first, then rest. + if locI == numberPrefix && locJ == numberPrefix { + if numI != numJ { + return numI < numJ + } + return restI < restJ + } + + // Both are suffix-ish (right-before-ext or trailing): rest first, then + // number. + if locI != numberPrefix && locJ != numberPrefix { + if restI != restJ { + return restI < restJ + } + if numI != numJ { + return numI < numJ + } + return s[i] < s[j] } - // If numbers are equal, falls back to string comparison of the rest. - return restI < restJ + + // Mixed: one prefix, one not. + if restI != restJ { + return restI < restJ + } + return locI == numberPrefix } - // If one has a numerical prefix and the other doesn't, the one with the - // number comes first. + // One has a number: it comes first. if numI != -1 { return true } @@ -42,28 +74,40 @@ func (s AlphanumericSort) Less(i, j int) bool { return false } - // If neither has a numerical prefix, compare as strings + // Neither has a number: plain lexicographic. return s[i] < s[j] } -// extractPrefix attempts to extract a numerical prefix and the rest of the filename -func extractPrefix(filename string) (int, string) { - matches := numPrefixRegexp.FindStringSubmatch(filename) - if len(matches) > 2 { - prefix, err := strconv.Atoi(matches[1]) - if err == nil { - return prefix, matches[2] +func extractNumber(str string) (int, string, numberLoc) { + str = filepath.Base(str) + + if matches := prefixRegexp.FindStringSubmatch(str); len(matches) > 2 { + if num, err := strconv.Atoi(matches[1]); err == nil { + return num, matches[2], numberPrefix } } - - // Returns -1 if no numerical prefix is found, indicating to just compare - // as strings. - return -1, filename + if matches := extensionSuffixRegexp.FindStringSubmatch(str); len(matches) > 3 { + if num, err := strconv.Atoi(matches[2]); err == nil { + return num, matches[1] + matches[3], numberExtSuffix + } + } + if matches := suffixRegexp.FindStringSubmatch(str); len(matches) > 2 { + if num, err := strconv.Atoi(matches[2]); err == nil { + return num, matches[1], numberSuffix + } + } + return -1, str, numberNone } -var numPrefixRegexp = regexp.MustCompile(`^(\d+)(.*)$`) - -// Interface guard. +// Regular expressions used by extractNumber. var ( - _ sort.Interface = (*AlphanumericSort)(nil) + // Matches a numeric prefix: one or more digits at the start. + prefixRegexp = regexp.MustCompile(`^(\d+)(.*)$`) + // Matches a numeric block immediately before a file extension. + extensionSuffixRegexp = regexp.MustCompile(`^(.*?)(\d+)(\.[^.]+)$`) + // Matches a trailing numeric sequence when there is no extension. + suffixRegexp = regexp.MustCompile(`^(.*?)(\d+)$`) ) + +// Interface guard. +var _ sort.Interface = (*AlphanumericSort)(nil) diff --git a/pkg/gotenberg/sort_test.go b/pkg/gotenberg/sort_test.go index 94b291660..1bf220fbe 100644 --- a/pkg/gotenberg/sort_test.go +++ b/pkg/gotenberg/sort_test.go @@ -17,11 +17,27 @@ 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"}, expectedSort: []string{"1bar.pdf", "2_aza.txt", "2_baz.txt", "3.pdf", "4_foo.pdf", "10qux.pdf", "25zeta.txt", "Afoo.txt", "Bbar.docx"}, }, + { + scenario: "numeric suffixes with extensions", + values: []string{"sample1_10.pdf", "sample1_11.pdf", "sample1_4.pdf", "sample1_3.pdf", "sample1_1.pdf", "sample1_2.pdf"}, + expectedSort: []string{"sample1_1.pdf", "sample1_2.pdf", "sample1_3.pdf", "sample1_4.pdf", "sample1_10.pdf", "sample1_11.pdf"}, + }, + { + scenario: "numeric suffixes", + values: []string{"sample1_10", "sample1_11", "sample1_4", "sample1_3", "sample1_1", "sample1_2"}, + expectedSort: []string{"sample1_1", "sample1_2", "sample1_3", "sample1_4", "sample1_10", "sample1_11"}, + }, { scenario: "hrtime (PHP library)", values: []string{"245654773395259", "245654773395039", "245654773395149", "245654773394919", "245654773394369"}, expectedSort: []string{"245654773394369", "245654773394919", "245654773395039", "245654773395149", "245654773395259"}, }, + { + // https://github.com/gotenberg/gotenberg/issues/1287. + scenario: "different basenames with numeric suffixes", + values: []string{"RIJNMOND-attach-Opdrachtbevestiging_P0007104.pdf", "Bundle-25029.pdf"}, + expectedSort: []string{"Bundle-25029.pdf", "RIJNMOND-attach-Opdrachtbevestiging_P0007104.pdf"}, + }, } { t.Run(tc.scenario, func(t *testing.T) { sort.Sort(AlphanumericSort(tc.values)) diff --git a/pkg/modules/api/api.go b/pkg/modules/api/api.go index 3d6cb4598..3863bcac7 100644 --- a/pkg/modules/api/api.go +++ b/pkg/modules/api/api.go @@ -26,7 +26,7 @@ func init() { gotenberg.MustRegisterModule(new(Api)) } -// Api is a module which provides an HTTP server. Other modules may add routes, +// Api is a module that provides an HTTP server. Other modules may add routes, // middlewares or health checks. type Api struct { port int @@ -42,11 +42,13 @@ type Api struct { basicAuthPassword string downloadFromCfg downloadFromConfig disableHealthCheckLogging bool + enableDebugRoute bool routes []Route externalMiddlewares []Middleware healthChecks []health.CheckerOption readyFn []func() error + asyncCounters []AsynchronousCounter fs *gotenberg.FileSystem logger *zap.Logger srv *echo.Echo @@ -59,7 +61,7 @@ type downloadFromConfig struct { disable bool } -// Router is a module interface which adds routes to the [Api]. +// Router is a module interface that adds routes to the [Api]. type Router interface { Routes() ([]Route, error) } @@ -82,17 +84,17 @@ type Route struct { // Optional. DisableLogging bool - // Handler is the function which handles the request. + // Handler is the function that handles the request. // Required. Handler echo.HandlerFunc } -// MiddlewareProvider is a module interface which adds middlewares to the [Api]. +// MiddlewareProvider is a module interface that adds middlewares to the [Api]. type MiddlewareProvider interface { Middlewares() ([]Middleware, error) } -// MiddlewareStack is a type which helps to determine in which stack the +// MiddlewareStack is a type that helps to determine in which stack the // middlewares provided by the [MiddlewareProvider] modules should be located. type MiddlewareStack uint32 @@ -102,7 +104,7 @@ const ( MultipartStack ) -// MiddlewarePriority is a type which helps to determine the execution order of +// MiddlewarePriority is a type that helps to determine the execution order of // middlewares provided by the [MiddlewareProvider] modules in a stack. type MiddlewarePriority uint32 @@ -114,7 +116,7 @@ const ( VeryHighPriority ) -// Middleware is a middleware which can be added to the [Api]'s middlewares +// Middleware is a middleware that can be added to the [Api]'s middlewares // chain. // // middleware := Middleware{ @@ -156,7 +158,7 @@ type Middleware struct { Handler echo.MiddlewareFunc } -// HealthChecker is a module interface which allows adding health checks to the +// HealthChecker is a module interface that allows adding health checks to the // API. // // See https://github.com/alexliesenfeld/health for more details. @@ -165,6 +167,14 @@ type HealthChecker interface { Ready() error } +// AsynchronousCounter is a module interface that returns the number of active +// asynchronous requests. +// +// See https://github.com/gotenberg/gotenberg/issues/1022. +type AsynchronousCounter interface { + AsyncCount() int64 +} + // Descriptor returns an [Api]'s module descriptor. func (a *Api) Descriptor() gotenberg.ModuleDescriptor { return gotenberg.ModuleDescriptor{ @@ -187,6 +197,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.Bool("api-disable-download-from", false, "Disable the download from feature") fs.Bool("api-disable-health-check-logging", false, "Disable health check logging") + fs.Bool("api-enable-debug-route", false, "Enable the debug route") return fs }(), New: func() gotenberg.Module { return new(Api) }, @@ -212,6 +223,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error { disable: flags.MustBool("api-disable-download-from"), } a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging") + a.enableDebugRoute = flags.MustBool("api-enable-debug-route") // Port from env? portEnvVar := flags.MustString("api-port-from-env") @@ -304,6 +316,17 @@ func (a *Api) Provision(ctx *gotenberg.Context) error { a.readyFn = append(a.readyFn, healthChecker.Ready) } + // Get asynchronous counters. + mods, err = ctx.Modules(new(AsynchronousCounter)) + if err != nil { + return fmt.Errorf("get asynchronous counters: %w", err) + } + + a.asyncCounters = make([]AsynchronousCounter, len(mods)) + for i, asyncCounter := range mods { + a.asyncCounters[i] = asyncCounter.(AsynchronousCounter) + } + // Logger. loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider)) if err != nil { @@ -318,7 +341,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error { a.logger = logger // File system. - a.fs = gotenberg.NewFileSystem() + a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) return nil } @@ -365,9 +388,10 @@ func (a *Api) Validate() error { return err } - routesMap := make(map[string]string, len(a.routes)+2) + routesMap := make(map[string]string, len(a.routes)+3) routesMap["/health"] = "/health" routesMap["/version"] = "/version" + routesMap["/debug"] = "/debug" for _, route := range a.routes { if route.Path == "" { @@ -427,7 +451,7 @@ func (a *Api) Start() error { } } - // Check if the user wish to add logging entries related to the health + // Check if the user wishes to add logging entries related to the health // check route. if a.disableHealthCheckLogging { disableLoggingForPaths = append(disableLoggingForPaths, "health") @@ -438,6 +462,7 @@ func (a *Api) Start() error { latencyMiddleware(), rootPathMiddleware(a.rootPath), traceMiddleware(a.traceHeader), + outputFilenameMiddleware(), loggerMiddleware(a.logger, disableLoggingForPaths), ) @@ -456,14 +481,22 @@ func (a *Api) Start() error { hardTimeout := a.timeout + (time.Duration(5) * time.Second) + // Basic auth? + var securityMiddleware echo.MiddlewareFunc + if a.basicAuthUsername != "" { + securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword) + } else { + securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + return next(c) + } + } + } + // Add the modules' routes and their specific middlewares. for _, route := range a.routes { var middlewares []echo.MiddlewareFunc - - // Basic auth? - if a.basicAuthUsername != "" { - middlewares = append(middlewares, basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword)) - } + middlewares = append(middlewares, securityMiddleware) if route.IsMultipart { middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg)) @@ -483,6 +516,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 documentation to learn how to interact with it ๐Ÿš€`) + }, + securityMiddleware, + ) + + // Favicon route. + a.srv.GET( + fmt.Sprintf("%s%s", a.rootPath, "favicon.ico"), + func(c echo.Context) error { + return c.NoContent(http.StatusNoContent) + }, + securityMiddleware, + ) + // Let's not forget the health check routes... checks := append(a.healthChecks, health.WithTimeout(a.timeout)) checker := health.NewChecker(checks...) @@ -503,14 +554,26 @@ func (a *Api) Start() error { hardTimeoutMiddleware(hardTimeout), ) - // ...and the version route. + // ...the version route. a.srv.GET( fmt.Sprintf("%s%s", a.rootPath, "version"), func(c echo.Context) error { return c.String(http.StatusOK, gotenberg.Version) }, + securityMiddleware, ) + // ...and the debug route. + if a.enableDebugRoute { + a.srv.GET( + fmt.Sprintf("%s%s", a.rootPath, "debug"), + func(c echo.Context) error { + return c.JSONPretty(http.StatusOK, gotenberg.Debug(), " ") + }, + securityMiddleware, + ) + } + // Wait for all modules to be ready. ctx, cancel := context.WithTimeout(context.Background(), a.startTimeout) defer cancel() @@ -555,7 +618,28 @@ func (a *Api) StartupMessage() string { // Stop stops the HTTP server. func (a *Api) Stop(ctx context.Context) error { - return a.srv.Shutdown(ctx) + for { + count := int64(0) + for _, asyncCounter := range a.asyncCounters { + count += asyncCounter.AsyncCount() + } + select { + case <-ctx.Done(): + return a.srv.Shutdown(ctx) + default: + a.logger.Debug(fmt.Sprintf("%d asynchronous requests", count)) + if count > 0 { + time.Sleep(1 * time.Second) + continue + } + a.logger.Debug("no more asynchronous requests, continue with shutdown") + err := a.srv.Shutdown(ctx) + if err != nil { + return fmt.Errorf("shutdown: %w", err) + } + return gotenberg.ErrCancelGracefulShutdownContext + } + } } // Interface guards. diff --git a/pkg/modules/api/api_test.go b/pkg/modules/api/api_test.go deleted file mode 100644 index 885076af4..000000000 --- a/pkg/modules/api/api_test.go +++ /dev/null @@ -1,1003 +0,0 @@ -package api - -import ( - "bytes" - "context" - "errors" - "mime/multipart" - "net/http" - "net/http/httptest" - "os" - "reflect" - "testing" - "time" - - "github.com/alexliesenfeld/health" - "github.com/labstack/echo/v4" - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestApi_Descriptor(t *testing.T) { - descriptor := new(Api).Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(Api)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestApi_Provision(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *gotenberg.Context - setEnv func() - expectPort int - expectMiddlewares []Middleware - expectError bool - }{ - { - scenario: "port from env: non-existing environment variable", - ctx: func() *gotenberg.Context { - fs := new(Api).Descriptor().FlagSet - err := fs.Parse([]string{"--api-port-from-env=FOO"}) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: fs, - }, - nil, - ) - }(), - expectError: true, - }, - { - scenario: "port from env: invalid environment variable value", - ctx: func() *gotenberg.Context { - fs := new(Api).Descriptor().FlagSet - err := fs.Parse([]string{"--api-port-from-env=PORT"}) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: fs, - }, - nil, - ) - }(), - setEnv: func() { - err := os.Setenv("PORT", "foo") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }, - expectError: true, - }, - { - scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_USERNAME environment variable", - ctx: func() *gotenberg.Context { - fs := new(Api).Descriptor().FlagSet - err := fs.Parse([]string{"--api-enable-basic-auth=true"}) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: fs, - }, - nil, - ) - }(), - expectError: true, - }, - { - scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_PASSWORD environment variable", - ctx: func() *gotenberg.Context { - fs := new(Api).Descriptor().FlagSet - err := fs.Parse([]string{"--api-enable-basic-auth=true"}) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: fs, - }, - nil, - ) - }(), - setEnv: func() { - err := os.Setenv("GOTENBERG_API_BASIC_AUTH_USERNAME", "foo") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }, - expectError: true, - }, - { - scenario: "no valid routers", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - gotenberg.ValidatorMock - RouterMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.ValidateMock = func() error { - return errors.New("foo") - } - mod.RoutesMock = func() ([]Route, error) { - return nil, nil - } - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Api).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "cannot retrieve routes from router", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - RouterMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.RoutesMock = func() ([]Route, error) { - return nil, errors.New("foo") - } - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Api).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "no valid middleware providers", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - gotenberg.ValidatorMock - MiddlewareProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.ValidateMock = func() error { - return errors.New("foo") - } - mod.MiddlewaresMock = func() ([]Middleware, error) { - return nil, nil - } - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Api).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "cannot retrieve middlewares from middleware provider", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - MiddlewareProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.MiddlewaresMock = func() ([]Middleware, error) { - return nil, errors.New("foo") - } - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Api).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "no valid health checkers", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - gotenberg.ValidatorMock - HealthCheckerMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.ValidateMock = func() error { - return errors.New("foo") - } - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Api).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "cannot retrieve health checks from health checker", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - HealthCheckerMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.ChecksMock = func() ([]health.CheckerOption, error) { - return nil, errors.New("foo") - } - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Api).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "no logger provider", - ctx: func() *gotenberg.Context { - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Api).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{}, - ) - }(), - expectError: true, - }, - { - scenario: "no logger from logger provider", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - gotenberg.LoggerProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { - return nil, errors.New("foo") - } - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Api).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "success", - ctx: func() *gotenberg.Context { - mod1 := &struct { - gotenberg.ModuleMock - RouterMock - }{} - mod1.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }} - } - mod1.RoutesMock = func() ([]Route, error) { - return []Route{{}}, nil - } - - mod2 := &struct { - gotenberg.ModuleMock - MiddlewareProviderMock - }{} - mod2.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }} - } - mod2.MiddlewaresMock = func() ([]Middleware, error) { - return []Middleware{ - { - Priority: VeryLowPriority, - }, - { - Priority: LowPriority, - }, - { - Priority: MediumPriority, - }, - { - Priority: HighPriority, - }, - { - Priority: VeryHighPriority, - }, - }, nil - } - - mod3 := &struct { - gotenberg.ModuleMock - HealthCheckerMock - }{} - mod3.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return mod3 }} - } - mod3.ChecksMock = func() ([]health.CheckerOption, error) { - return []health.CheckerOption{health.WithDisabledAutostart()}, nil - } - mod3.ReadyMock = func() error { - return nil - } - - mod4 := &struct { - gotenberg.ModuleMock - gotenberg.LoggerProviderMock - }{} - mod4.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "qux", New: func() gotenberg.Module { return mod4 }} - } - mod4.LoggerMock = func(_ gotenberg.Module) (*zap.Logger, error) { - return zap.NewNop(), nil - } - - fs := new(Api).Descriptor().FlagSet - err := fs.Parse([]string{"--api-port-from-env=PORT", "--api-enable-basic-auth=true"}) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: fs, - }, - []gotenberg.ModuleDescriptor{ - mod1.Descriptor(), - mod2.Descriptor(), - mod3.Descriptor(), - mod4.Descriptor(), - }, - ) - }(), - setEnv: func() { - err := os.Setenv("PORT", "1337") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - err = os.Setenv("GOTENBERG_API_BASIC_AUTH_USERNAME", "foo") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - err = os.Setenv("GOTENBERG_API_BASIC_AUTH_PASSWORD", "bar") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }, - expectPort: 1337, - expectMiddlewares: []Middleware{ - { - Priority: VeryHighPriority, - }, - { - Priority: HighPriority, - }, - { - Priority: MediumPriority, - }, - { - Priority: LowPriority, - }, - { - Priority: VeryLowPriority, - }, - }, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - if tc.setEnv != nil { - tc.setEnv() - } - - mod := new(Api) - err := mod.Provision(tc.ctx) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - - if tc.expectPort != 0 && mod.port != tc.expectPort { - t.Errorf("expected port %d but got %d", tc.expectPort, mod.port) - } - - if !reflect.DeepEqual(mod.externalMiddlewares, tc.expectMiddlewares) { - t.Errorf("expected %+v, but got: %+v", tc.expectMiddlewares, mod.externalMiddlewares) - } - }) - } -} - -func TestApi_Validate(t *testing.T) { - for _, tc := range []struct { - scenario string - port int - bindIp string - tlsCertFile string - tlsKeyFile string - rootPath string - traceHeader string - routes []Route - middlewares []Middleware - expectError bool - }{ - { - scenario: "invalid port (< 1)", - port: 0, - bindIp: "127.0.0.1", - rootPath: "/foo/", - traceHeader: "foo", - routes: nil, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid port (> 65535)", - port: 65536, - bindIp: "127.0.0.1", - rootPath: "/foo/", - traceHeader: "foo", - routes: nil, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid IP", - port: 10, - bindIp: "foo", - rootPath: "/foo/", - traceHeader: "foo", - routes: nil, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid TLS files: only cert file provided", - port: 10, - bindIp: "127.0.0.1", - tlsCertFile: "cert.pem", - rootPath: "/foo/", - traceHeader: "foo", - routes: nil, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid TLS files: only key file provided", - port: 10, - bindIp: "127.0.0.1", - tlsKeyFile: "key.pem", - rootPath: "/foo/", - traceHeader: "foo", - routes: nil, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid root path: missing / prefix", - port: 10, - bindIp: "127.0.0.1", - rootPath: "foo/", - traceHeader: "foo", - routes: nil, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid root path: missing / suffix", - port: 10, - bindIp: "127.0.0.1", - rootPath: "/foo", - traceHeader: "foo", - routes: nil, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid trace header", - port: 10, - bindIp: "127.0.0.1", - rootPath: "/foo/", - traceHeader: "", - routes: nil, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid route: empty path", - port: 10, - bindIp: "127.0.0.1", - rootPath: "/foo/", - traceHeader: "foo", - routes: []Route{ - { - Path: "", - }, - }, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid route: missing / prefix in path", - port: 10, - bindIp: "127.0.0.1", - rootPath: "/foo/", - traceHeader: "foo", - routes: []Route{ - { - Path: "foo", - }, - }, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid multipart route: no /forms prefix in path", - port: 10, - bindIp: "127.0.0.1", - rootPath: "/foo/", - traceHeader: "foo", - routes: []Route{ - { - Path: "/foo", - IsMultipart: true, - }, - }, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid route: no method", - port: 10, - bindIp: "127.0.0.1", - rootPath: "/foo/", - traceHeader: "foo", - routes: []Route{ - { - Path: "/foo", - Method: "", - }, - }, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid route: nil handler", - port: 10, - bindIp: "127.0.0.1", - rootPath: "/foo/", - traceHeader: "foo", - routes: []Route{ - { - Method: http.MethodPost, - Path: "/foo", - Handler: nil, - }, - }, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid route: path already existing", - port: 10, - bindIp: "127.0.0.1", - rootPath: "/foo/", - traceHeader: "foo", - routes: []Route{ - { - Method: http.MethodPost, - Path: "/foo", - Handler: func(_ echo.Context) error { return nil }, - }, - { - Method: http.MethodPost, - Path: "/foo", - Handler: func(_ echo.Context) error { return nil }, - }, - }, - middlewares: nil, - expectError: true, - }, - { - scenario: "invalid middleware: nil handler", - port: 10, - bindIp: "127.0.0.1", - rootPath: "/foo/", - traceHeader: "foo", - routes: nil, - middlewares: []Middleware{ - { - Priority: HighPriority, - Handler: nil, - }, - }, - expectError: true, - }, - { - scenario: "success", - port: 10, - bindIp: "127.0.0.1", - rootPath: "/foo/", - traceHeader: "foo", - routes: []Route{ - { - Method: http.MethodGet, - Path: "/foo", - Handler: func(_ echo.Context) error { return nil }, - }, - { - Method: http.MethodGet, - Path: "/forms/foo", - Handler: func(_ echo.Context) error { return nil }, - IsMultipart: true, - }, - }, - middlewares: []Middleware{ - { - Priority: HighPriority, - Handler: func() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - return next(c) - } - } - }(), - }, - }, - }, - { - scenario: "success with TLS", - port: 10, - tlsCertFile: "cert.pem", - tlsKeyFile: "key.pem", - rootPath: "/foo/", - traceHeader: "foo", - routes: []Route{ - { - Method: http.MethodGet, - Path: "/foo", - Handler: func(_ echo.Context) error { return nil }, - }, - { - Method: http.MethodGet, - Path: "/forms/foo", - Handler: func(_ echo.Context) error { return nil }, - IsMultipart: true, - }, - }, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := Api{ - port: tc.port, - bindIp: tc.bindIp, - tlsCertFile: tc.tlsCertFile, - tlsKeyFile: tc.tlsKeyFile, - rootPath: tc.rootPath, - traceHeader: tc.traceHeader, - routes: tc.routes, - externalMiddlewares: tc.middlewares, - } - - err := mod.Validate() - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestApi_Start(t *testing.T) { - for _, tc := range []struct { - scenario string - readyFn []func() error - tlsCertFile string - tlsKeyFile string - expectError bool - }{ - { - scenario: "at least one module not ready", - readyFn: []func() error{ - func() error { return nil }, - func() error { return errors.New("not ready") }, - }, - expectError: true, - }, - { - scenario: "success", - readyFn: []func() error{ - func() error { return nil }, - func() error { return nil }, - }, - expectError: false, - }, - { - scenario: "success with TLS", - readyFn: []func() error{ - func() error { return nil }, - func() error { return nil }, - }, - tlsCertFile: "/tests/test/testdata/api/cert.pem", - tlsKeyFile: "/tests/test/testdata/api/key.pem", - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(Api) - mod.port = 3000 - mod.tlsCertFile = tc.tlsCertFile - mod.tlsKeyFile = tc.tlsKeyFile - mod.startTimeout = time.Duration(30) * time.Second - mod.rootPath = "/" - mod.basicAuthUsername = "foo" - mod.basicAuthPassword = "bar" - mod.disableHealthCheckLogging = true - mod.routes = []Route{ - { - Method: http.MethodPost, - Path: "/forms/foo", - IsMultipart: true, - DisableLogging: true, - Handler: func(c echo.Context) error { - ctx := c.Get("context").(*Context) - ctx.outputPaths = []string{ - "/tests/test/testdata/api/sample1.txt", - } - - return nil - }, - }, - { - Method: http.MethodPost, - Path: "/forms/bar", - IsMultipart: true, - Handler: func(_ echo.Context) error { return errors.New("foo") }, - }, - } - mod.externalMiddlewares = []Middleware{ - { - Stack: PreRouterStack, - Handler: func() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - return next(c) - } - } - }(), - }, - { - Stack: MultipartStack, - Handler: func() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - return next(c) - } - } - }(), - }, - { - Stack: DefaultStack, - Handler: func() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - return next(c) - } - } - }(), - }, - { - Handler: func() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - return next(c) - } - } - }(), - }, - } - mod.readyFn = tc.readyFn - mod.fs = gotenberg.NewFileSystem() - mod.logger = zap.NewNop() - - err := mod.Start() - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - - if tc.expectError { - return - } - - // health requests. - recorder := httptest.NewRecorder() - - healthGetRequest := httptest.NewRequest(http.MethodGet, "/health", nil) - mod.srv.ServeHTTP(recorder, healthGetRequest) - if recorder.Code != http.StatusOK { - t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) - } - - healthHeadRequest := httptest.NewRequest(http.MethodHead, "/health", nil) - mod.srv.ServeHTTP(recorder, healthHeadRequest) - if recorder.Code != http.StatusOK { - t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) - } - - // version request. - versionRequest := httptest.NewRequest(http.MethodGet, "/version", nil) - mod.srv.ServeHTTP(recorder, versionRequest) - if recorder.Code != http.StatusOK { - t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) - } - - // "multipart/form-data" request. - multipartRequest := func(url string) *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - - err := writer.WriteField("foo", "foo") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - part, err := writer.CreateFormFile("foo.txt", "foo.txt") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - _, err = part.Write([]byte("foo")) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - req := httptest.NewRequest(http.MethodPost, url, body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - req.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword) - - return req - } - - recorder = httptest.NewRecorder() - mod.srv.ServeHTTP(recorder, multipartRequest("/forms/foo")) - - if recorder.Code != http.StatusOK { - t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) - } - - recorder = httptest.NewRecorder() - mod.srv.ServeHTTP(recorder, multipartRequest("/forms/bar")) - - if recorder.Code != http.StatusInternalServerError { - t.Errorf("expected %d status code but got %d", http.StatusInternalServerError, recorder.Code) - } - - err = mod.srv.Shutdown(context.TODO()) - if err != nil { - t.Errorf("expected no error but got: %v", err) - } - }) - } -} - -func TestApi_StartupMessage(t *testing.T) { - for _, tc := range []struct { - scenario string - port int - bindIp string - expectMessage string - }{ - { - scenario: "no custom IP", - port: 3000, - bindIp: "", - expectMessage: "server started on [::]:3000", - }, - { - scenario: "custom IP", - port: 3000, - bindIp: "127.0.0.1", - expectMessage: "server started on 127.0.0.1:3000", - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := Api{ - port: tc.port, - bindIp: tc.bindIp, - } - - actual := mod.StartupMessage() - if actual != tc.expectMessage { - t.Errorf("expected '%s' but got '%s'", tc.expectMessage, actual) - } - }) - } -} - -func TestApi_Stop(t *testing.T) { - mod := &Api{ - port: 3000, - routes: []Route{ - { - Method: http.MethodGet, - Path: "/foo", - Handler: func(_ echo.Context) error { return nil }, - }, - }, - logger: zap.NewNop(), - } - - err := mod.Start() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - err = mod.Stop(context.TODO()) - if err != nil { - t.Errorf("expected no error but got: %v", err) - } -} diff --git a/pkg/modules/api/context.go b/pkg/modules/api/context.go index 7d895810d..4b390da39 100644 --- a/pkg/modules/api/context.go +++ b/pkg/modules/api/context.go @@ -1,7 +1,6 @@ package api import ( - "compress/flate" "context" "encoding/json" "errors" @@ -19,7 +18,7 @@ import ( "github.com/google/uuid" "github.com/hashicorp/go-retryablehttp" "github.com/labstack/echo/v4" - "github.com/mholt/archiver/v3" + "github.com/mholt/archives" "go.uber.org/zap" "golang.org/x/sync/errgroup" "golang.org/x/text/unicode/norm" @@ -39,14 +38,16 @@ var ( // Context is the request context for a "multipart/form-data" requests. type Context struct { - dirPath string - values map[string][]string - files map[string]string - outputPaths []string - cancelled bool + dirPath string + values map[string][]string + files map[string]string + filesByField map[string][]string + outputPaths []string + cancelled bool logger *zap.Logger echoCtx echo.Context + mkdirAll gotenberg.MkdirAll pathRename gotenberg.PathRename context.Context } @@ -79,12 +80,9 @@ type downloadFrom struct { // ExtraHttpHeaders are the HTTP headers to send alongside. ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"` -} - -type osPathRename struct{} -func (o *osPathRename) Rename(oldpath, newpath string) error { - return os.Rename(oldpath, newpath) + // Download as embed file + Embedded bool `json:"embedded"` } // newContext returns a [Context] by parsing a "multipart/form-data" request. @@ -112,7 +110,8 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst cancelled: false, logger: logger, echoCtx: echoCtx, - pathRename: new(osPathRename), + mkdirAll: new(gotenberg.OsMkdirAll), + pathRename: new(gotenberg.OsPathRename), Context: processCtx, } @@ -189,6 +188,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst ctx.dirPath = dirPath ctx.values = form.Value ctx.files = make(map[string]string) + ctx.filesByField = make(map[string][]string) // First, try to download files listed in the "downloadFrom" form field, if // any. @@ -323,6 +323,9 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst } ctx.files[filename] = path + if dl.Embedded { + ctx.filesByField[EmbedsFormField] = append(ctx.filesByField[EmbedsFormField], path) + } return nil }) @@ -378,17 +381,22 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst } // Then, copy the form files, if any. - for _, files := range form.File { + for fieldName, files := range form.File { for _, fh := range files { err = copyToDisk(fh) if err != nil { return ctx, cancel, fmt.Errorf("copy to disk: %w", err) } + // Track files by field name + filename := norm.NFC.String(filepath.Base(fh.Filename)) + filePath := ctx.files[filename] + ctx.filesByField[fieldName] = append(ctx.filesByField[fieldName], filePath) } } ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values)) ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files)) + ctx.Log().Debug(fmt.Sprintf("form files by field: %+v", ctx.filesByField)) ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load())) return ctx, cancel, err @@ -402,9 +410,10 @@ func (ctx *Context) Request() *http.Request { // FormData return a [FormData]. func (ctx *Context) FormData() *FormData { return &FormData{ - values: ctx.values, - files: ctx.files, - errors: nil, + values: ctx.values, + files: ctx.files, + filesByField: ctx.filesByField, + errors: nil, } } @@ -414,9 +423,28 @@ func (ctx *Context) GeneratePath(extension string) string { return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New().String(), extension) } +// GeneratePathFromFilename generates a path within the context's working +// directory, using the given filename (with extension). It does not create +// a file. +func (ctx *Context) GeneratePathFromFilename(filename string) string { + return fmt.Sprintf("%s/%s", ctx.dirPath, filename) +} + +// CreateSubDirectory creates a subdirectory within the context's working +// directory. +func (ctx *Context) CreateSubDirectory(dirName string) (string, error) { + path := fmt.Sprintf("%s/%s", ctx.dirPath, dirName) + err := ctx.mkdirAll.MkdirAll(path, 0o755) + if err != nil { + return "", fmt.Errorf("create sub-directory %s: %w", path, err) + } + return path, nil +} + // Rename is just a wrapper around [os.Rename], as we need to mock this // behavior in our tests. func (ctx *Context) Rename(oldpath, newpath string) error { + ctx.Log().Debug(fmt.Sprintf("rename %s to %s", oldpath, newpath)) err := ctx.pathRename.Rename(oldpath, newpath) if err != nil { return fmt.Errorf("rename path: %w", err) @@ -460,22 +488,33 @@ func (ctx *Context) BuildOutputFile() (string, error) { if len(ctx.outputPaths) == 1 { ctx.logger.Debug(fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0])) - return ctx.outputPaths[0], nil } - z := archiver.Zip{ - CompressionLevel: flate.DefaultCompression, - MkdirAll: true, - SelectiveCompression: true, - ContinueOnError: false, - OverwriteExisting: false, - ImplicitTopLevelFolder: false, + filesInfo, err := archives.FilesFromDisk(ctx.Context, nil, func() map[string]string { + f := make(map[string]string) + for _, outputPath := range ctx.outputPaths { + f[outputPath] = "" + } + return f + }()) + if err != nil { + return "", fmt.Errorf("create files info: %w", err) } archivePath := ctx.GeneratePath(".zip") + out, err := os.Create(archivePath) + if err != nil { + return "", fmt.Errorf("create zip file: %w", err) + } + defer func(out *os.File) { + err := out.Close() + if err != nil { + ctx.logger.Error(fmt.Sprintf("close zip file: %s", err)) + } + }(out) - err := z.Archive(ctx.outputPaths, archivePath) + err = archives.Zip{}.Archive(ctx.Context, out, filesInfo) if err != nil { return "", fmt.Errorf("archive output files: %w", err) } @@ -488,7 +527,7 @@ func (ctx *Context) BuildOutputFile() (string, error) { // OutputFilename returns the filename based on the given output path or the // "Gotenberg-Output-Filename" header's value. func (ctx *Context) OutputFilename(outputPath string) string { - filename := ctx.echoCtx.Request().Header.Get("Gotenberg-Output-Filename") + filename := ctx.echoCtx.Get("outputFilename").(string) if filename == "" { return filepath.Base(outputPath) @@ -496,8 +535,3 @@ func (ctx *Context) OutputFilename(outputPath string) string { return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath)) } - -// Interface guard. -var ( - _ gotenberg.PathRename = (*osPathRename)(nil) -) diff --git a/pkg/modules/api/context_test.go b/pkg/modules/api/context_test.go deleted file mode 100644 index ddb7e9f35..000000000 --- a/pkg/modules/api/context_test.go +++ /dev/null @@ -1,855 +0,0 @@ -package api - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "time" - - "github.com/dlclark/regexp2" - "github.com/google/uuid" - "github.com/labstack/echo/v4" - "go.uber.org/zap" - - "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) { - defaultAllowList, err := regexp2.Compile("", 0) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - defaultDenyList, err := regexp2.Compile("", 0) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - defaultDownloadFromCfg := downloadFromConfig{ - allowList: defaultAllowList, - denyList: defaultDenyList, - maxRetry: 1, - disable: false, - } - - for _, tc := range []struct { - scenario string - request *http.Request - bodyLimit int64 - downloadFromCfg downloadFromConfig - downloadFromSrv *echo.Echo - expectContext *Context - expectError bool - expectHttpError bool - expectHttpStatus int - }{ - { - scenario: "http.ErrNotMultipart", - request: httptest.NewRequest(http.MethodPost, "/", nil), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusUnsupportedMediaType, - }, - { - scenario: "http.ErrMissingBoundary", - request: func() *http.Request { - req := httptest.NewRequest(http.MethodPost, "/", nil) - req.Header.Set(echo.HeaderContentType, echo.MIMEMultipartForm) - return req - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusUnsupportedMediaType, - }, - { - scenario: "malformed body", - request: func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - err := writer.WriteField("foo", "foo") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/", nil) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - return req - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "request entity too large: form values", - request: func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - err := writer.WriteField("key", "value") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - return req - }(), - bodyLimit: 1, - downloadFromCfg: defaultDownloadFromCfg, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusRequestEntityTooLarge, - }, - { - scenario: "request entity too large: downloadFrom", - request: func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - return req - }(), - bodyLimit: 45, // form values = 44 bytes. - downloadFromSrv: func() *echo.Echo { - srv := echo.New() - srv.HideBanner = true - srv.GET("/", func(c echo.Context) error { - c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`) - c.Response().Header().Set(echo.HeaderContentType, "text/plain") - return c.String(http.StatusOK, http.StatusText(http.StatusOK)) - }) - return srv - }(), - downloadFromCfg: defaultDownloadFromCfg, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusRequestEntityTooLarge, - }, - { - scenario: "request entity too large: form files", - request: func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - part, err := writer.CreateFormFile("foo.txt", "foo.txt") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - _, err = part.Write([]byte("foo")) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - return req - }(), - bodyLimit: 1, - downloadFromCfg: defaultDownloadFromCfg, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusRequestEntityTooLarge, - }, - { - scenario: "invalid downloadFrom form field: cannot unmarshal", - request: func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - err := writer.WriteField("downloadFrom", "foo") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - return req - }(), - downloadFromCfg: defaultDownloadFromCfg, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "invalid downloadFrom form field: no URL", - request: func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - err := writer.WriteField("downloadFrom", `[{}]`) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - return req - }(), - downloadFromCfg: defaultDownloadFromCfg, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "invalid downloadFrom form field: filtered URL", - request: func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - err := writer.WriteField("downloadFrom", `[{"url":"https://foo.bar"}]`) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - return req - }(), - downloadFromCfg: func() downloadFromConfig { - denyList, err := regexp2.Compile("https://foo.bar", 0) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - return downloadFromConfig{allowList: defaultAllowList, denyList: denyList, maxRetry: 1, disable: false} - }(), - expectError: true, - }, - { - scenario: "invalid downloadFrom form field: unreachable URL", - request: func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - return req - }(), - downloadFromCfg: defaultDownloadFromCfg, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "invalid downloadFrom form field: invalid status code", - request: func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - return req - }(), - downloadFromSrv: func() *echo.Echo { - srv := echo.New() - srv.HideBanner = true - srv.GET("/", func(c echo.Context) error { - return c.String(http.StatusNotFound, http.StatusText(http.StatusNotFound)) - }) - return srv - }(), - downloadFromCfg: defaultDownloadFromCfg, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "invalid downloadFrom form field: no 'Content-Disposition' header", - request: func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - return req - }(), - downloadFromSrv: func() *echo.Echo { - srv := echo.New() - srv.HideBanner = true - srv.GET("/", func(c echo.Context) error { - return c.String(http.StatusOK, http.StatusText(http.StatusOK)) - }) - return srv - }(), - downloadFromCfg: defaultDownloadFromCfg, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "invalid downloadFrom form field: malformed 'Content-Disposition' header", - request: func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - return req - }(), - downloadFromSrv: func() *echo.Echo { - srv := echo.New() - srv.HideBanner = true - srv.GET("/", func(c echo.Context) error { - c.Response().Header().Set(echo.HeaderContentDisposition, ";;") - return c.String(http.StatusOK, http.StatusText(http.StatusOK)) - }) - return srv - }(), - downloadFromCfg: defaultDownloadFromCfg, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "invalid downloadFrom form field: no filename parameter in 'Content-Disposition' header", - request: func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - return req - }(), - downloadFromSrv: func() *echo.Echo { - srv := echo.New() - srv.HideBanner = true - srv.GET("/", func(c echo.Context) error { - c.Response().Header().Set(echo.HeaderContentDisposition, "inline;") - return c.String(http.StatusOK, http.StatusText(http.StatusOK)) - }) - return srv - }(), - downloadFromCfg: defaultDownloadFromCfg, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "success", - request: func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - err := writer.WriteField("foo", "foo") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - part, err := writer.CreateFormFile("foo.txt", "foo.txt") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - _, err = part.Write([]byte("foo")) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - err = writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - return req - }(), - downloadFromSrv: func() *echo.Echo { - srv := echo.New() - srv.HideBanner = true - srv.GET("/", func(c echo.Context) error { - if c.Request().Header.Get("User-Agent") != "Gotenberg" { - t.Fatalf("expected 'Gotenberg' from header 'User-Agent', but got '%s'", c.Request().Header.Get("User-Agent")) - } - if c.Request().Header.Get("X-Foo") != "Bar" { - t.Fatalf("expected 'Bar' from header 'X-Foo', but got '%s'", c.Request().Header.Get("X-Foo")) - } - if c.Request().Header.Get("Gotenberg-Trace") != "123" { - t.Fatalf("expected '123' from header 'Gotenberg-Trace', but got '%s'", c.Request().Header.Get("Gotenberg-Trace")) - } - c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`) - c.Response().Header().Set(echo.HeaderContentType, "text/plain") - return c.String(http.StatusOK, http.StatusText(http.StatusOK)) - }) - return srv - }(), - downloadFromCfg: defaultDownloadFromCfg, - expectContext: &Context{ - values: map[string][]string{ - "foo": {"foo"}, - "downloadFrom": { - `[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`, - }, - }, - files: map[string]string{ - "foo.txt": "foo.txt", - "bar.txt": "bar.txt", // downloadFrom. - }, - }, - expectError: false, - expectHttpError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - if tc.downloadFromSrv != nil { - go func() { - err := tc.downloadFromSrv.Start(":80") - if !errors.Is(err, http.ErrServerClosed) { - t.Error(err) - return - } - }() - defer func() { - err := tc.downloadFromSrv.Shutdown(context.TODO()) - if err != nil { - t.Error(err) - } - }() - } - - 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") - defer cancel() - // Context already cancelled. - defer cancel() - - if err != nil { - return err - } - - if tc.expectContext != nil { - if !reflect.DeepEqual(tc.expectContext.values, ctx.values) { - t.Fatalf("expected context.values to be %v but got %v", tc.expectContext.values, ctx.values) - } - if len(tc.expectContext.files) != len(ctx.files) { - t.Fatalf("expected context.files to contain %d items but got %d", len(tc.expectContext.files), len(ctx.files)) - } - for key, value := range tc.expectContext.files { - if !strings.HasSuffix(ctx.files[key], value) { - t.Fatalf("expected context.files to contain '%s' but got '%s'", value, ctx.files[key]) - } - } - } - - return nil - } - - recorder := httptest.NewRecorder() - - srv := echo.New() - srv.HideBanner = true - srv.HidePort = true - - c := srv.NewContext(tc.request, recorder) - err := handler(c) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - }) - } -} - -func TestContext_Request(t *testing.T) { - request := httptest.NewRequest(http.MethodPost, "/", nil) - recorder := httptest.NewRecorder() - c := echo.New().NewContext(request, recorder) - - ctx := &Context{ - echoCtx: c, - } - - if !reflect.DeepEqual(ctx.Request(), c.Request()) { - t.Errorf("expected %v but got %v", ctx.Request(), c.Request()) - } -} - -func TestContext_FormData(t *testing.T) { - ctx := &Context{ - values: map[string][]string{ - "foo": {"foo"}, - }, - files: map[string]string{ - "foo.txt": "/foo.txt", - }, - } - - actual := ctx.FormData() - expect := &FormData{ - values: ctx.values, - files: ctx.files, - } - - if !reflect.DeepEqual(actual, expect) { - t.Errorf("expected %+v but got %+v", expect, actual) - } -} - -func TestContext_GeneratePath(t *testing.T) { - ctx := &Context{ - dirPath: "/foo", - } - - path := ctx.GeneratePath(".pdf") - if !strings.HasPrefix(path, ctx.dirPath) { - t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath) - } -} - -func TestContext_Rename(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *Context - expectError bool - }{ - { - scenario: "failure", - ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { - return errors.New("cannot rename") - }}}, - expectError: true, - }, - { - scenario: "success", - ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { - return nil - }}}, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - err := tc.ctx.Rename("", "") - - 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_AddOutputPaths(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *Context - path string - expectCount int - expectError bool - }{ - { - scenario: "ErrContextAlreadyClosed", - ctx: &Context{cancelled: true}, - expectCount: 0, - expectError: true, - }, - { - scenario: "ErrOutOfBoundsOutputPath", - ctx: &Context{dirPath: "/foo"}, - path: "/bar/foo.txt", - expectCount: 0, - expectError: true, - }, - { - scenario: "success", - ctx: &Context{dirPath: "/foo"}, - path: "/foo/foo.txt", - expectCount: 1, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - err := tc.ctx.AddOutputPaths(tc.path) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if len(tc.ctx.outputPaths) != tc.expectCount { - t.Errorf("expected %d output paths but got %d", tc.expectCount, len(tc.ctx.outputPaths)) - } - }) - } -} - -func TestContext_Log(t *testing.T) { - expect := zap.NewNop() - ctx := Context{logger: expect} - actual := ctx.Log() - - if !reflect.DeepEqual(actual, expect) { - t.Errorf("expected %v but got %v", expect, actual) - } -} - -func TestContext_BuildOutputFile(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *Context - expectError bool - }{ - { - scenario: "ErrContextAlreadyClosed", - ctx: &Context{cancelled: true}, - expectError: true, - }, - { - scenario: "no output path", - ctx: &Context{}, - expectError: true, - }, - { - scenario: "success: one output path", - ctx: &Context{outputPaths: []string{"foo.txt"}}, - expectError: false, - }, - { - scenario: "cannot archive: invalid output paths", - ctx: &Context{outputPaths: []string{"foo.txt", "foo.pdf"}}, - expectError: true, - }, - { - scenario: "success: many output paths", - ctx: &Context{ - outputPaths: []string{ - "/tests/test/testdata/api/sample1.txt", - "/tests/test/testdata/api/sample1.txt", - }, - }, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - fs := gotenberg.NewFileSystem() - dirPath, err := fs.MkdirAll() - if err != nil { - t.Fatalf("expected no erro but got: %v", err) - } - - defer func() { - err := os.RemoveAll(fs.WorkingDirPath()) - if err != nil { - t.Fatalf("expected no error while cleaning up but got: %v", err) - } - }() - - tc.ctx.dirPath = dirPath - tc.ctx.logger = zap.NewNop() - - _, err = tc.ctx.BuildOutputFile() - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }) - } -} - -func TestContext_OutputFilename(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *Context - outputPath string - expectOutputFilename string - }{ - { - scenario: "with Gotenberg-Output-Filename header", - ctx: func() *Context { - c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil) - c.Request().Header.Set("Gotenberg-Output-Filename", "foo") - return &Context{echoCtx: c} - }(), - outputPath: "/foo/bar.txt", - expectOutputFilename: "foo.txt", - }, - { - scenario: "without custom filename", - ctx: func() *Context { - c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil) - return &Context{echoCtx: c} - }(), - outputPath: "/foo/foo.txt", - expectOutputFilename: "foo.txt", - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - actual := tc.ctx.OutputFilename(tc.outputPath) - - if actual != tc.expectOutputFilename { - t.Errorf("expected '%s' but got '%s'", tc.expectOutputFilename, actual) - } - }) - } -} diff --git a/pkg/modules/api/doc.go b/pkg/modules/api/doc.go index 3c255a454..8b9c119e3 100644 --- a/pkg/modules/api/doc.go +++ b/pkg/modules/api/doc.go @@ -1,3 +1,3 @@ -// Package api provides a module which is an HTTP server. Other modules may +// Package api provides a module, which is an HTTP server. Other modules may // add multipart/form-data routes, middlewares, and health checks. package api diff --git a/pkg/modules/api/formdata.go b/pkg/modules/api/formdata.go index 5483415df..d2d5bf7b3 100644 --- a/pkg/modules/api/formdata.go +++ b/pkg/modules/api/formdata.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + "slices" "sort" "strconv" "strings" @@ -16,18 +17,24 @@ import ( "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) +// EmbedsFormField represents the form field name for embedding files. +const ( + EmbedsFormField string = "embeds" +) + // FormData is a helper for validating and hydrating values from a // "multipart/form-data" request. // // form := ctx.FormData() type FormData struct { - values map[string][]string - files map[string]string - errors error + values map[string][]string + files map[string]string + filesByField map[string][]string + errors error } // Validate returns nil or an error related to the [FormData] values, with a -// [SentinelHttpError] (status code 400, errors' details as message) wrapped +// [SentinelHttpError] (status code 400, errors' details as a message) wrapped // inside. // // var foo string @@ -96,7 +103,7 @@ func (form *FormData) Int(key string, target *int, defaultValue int) *FormData { } // MandatoryInt binds a form field to an int variable. It populates an -// error if the value is not int, is empty, or the "key" does not exist. +// error if the value is not int, or is empty, or the "key" does not exist. // // var foo int // @@ -116,7 +123,7 @@ func (form *FormData) Float64(key string, target *float64, defaultValue float64) } // MandatoryFloat64 binds a form field to a float64 variable. It populates -// an error if the is not float64, is empty, or the "key" does not exist. +// an error if the value is not float64, is empty, or the "key" does not exist. // // var foo float64 // @@ -136,8 +143,8 @@ func (form *FormData) Duration(key string, target *time.Duration, defaultValue t } // MandatoryDuration binds a form field to a time.Duration variable. It -// populates an error if the value is not time.Duration, is empty, or the "key" -// does not exist. +// populates an error if the value is not time.Duration, or is empty, or the +// "key" does not exist. // // var foo time.Duration // @@ -146,7 +153,7 @@ func (form *FormData) MandatoryDuration(key string, target *time.Duration) *Form return form.mustMandatoryField(key, target) } -// Inches binds a form field to a float64 variable. It populates an error +// Inches bind a form field to a float64 variable. It populates an error // if the value cannot be computed back to inches. // // var foo float64 @@ -303,7 +310,7 @@ func (form *FormData) Path(filename string, target *string) *FormData { return form.path(filename, target) } -// MandatoryPath binds the absolute path ofa form data file to a string +// MandatoryPath binds the absolute path of a form data file to a string // variable. It populates an error if the file does not exist. // // var path string @@ -348,7 +355,7 @@ func (form *FormData) MandatoryContent(filename string, target *string) *FormDat return form.readFile(path, filename, target) } -// Paths binds the absolute paths of form data files, according to a list of +// Paths bind the absolute paths of form data files, according to a list of // file extensions, to a string slice variable. // // var paths []string @@ -358,6 +365,26 @@ func (form *FormData) Paths(extensions []string, target *[]string) *FormData { return form.paths(extensions, target) } +// Embeds binds the absolute paths of form data files that should be +// embedded in the PDF. Only files uploaded with the "embeds" field name +// will be included. +// +// var embeds []string +// +// ctx.FormData().Embeds(&embeds) +func (form *FormData) Embeds(target *[]string) *FormData { + if form.errors != nil { + return form + } + + // Get files from the "embeds" field + if paths, ok := form.filesByField[EmbedsFormField]; ok { + *target = append(*target, paths...) + } + + return form +} + // MandatoryPaths binds the absolute paths of form data files, according to a // list of file extensions, to a string slice variable. It populates an error // if there is no file for given file extensions. @@ -379,10 +406,17 @@ func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *For return form } -// paths binds the absolute paths of form data files, according to a list of +// paths bind the absolute paths of form data files, according to a list of // file extensions, to a string slice variable. +// embeds are excluded. func (form *FormData) paths(extensions []string, target *[]string) *FormData { + embeds, ok := form.filesByField[EmbedsFormField] + for filename, path := range form.files { + if ok && slices.Contains(embeds, path) { + continue + } + for _, ext := range extensions { // See https://github.com/gotenberg/gotenberg/issues/228. if strings.ToLower(filepath.Ext(filename)) == ext { diff --git a/pkg/modules/api/formdata_test.go b/pkg/modules/api/formdata_test.go index 5ebd0b2f1..ca575836c 100644 --- a/pkg/modules/api/formdata_test.go +++ b/pkg/modules/api/formdata_test.go @@ -1425,36 +1425,36 @@ func TestFormData_Content(t *testing.T) { scenario: "file does exist without file extension", form: &FormData{ files: map[string]string{ - "foo": "/tests/test/testdata/api/sample1.txt", + "foo": "testdata/sample.txt", }, }, filename: "foo", defaultValue: "", - expect: "foo", + expect: "This is a text from a text file.", expectError: false, }, { scenario: "file does exist with an uppercase file extension", form: &FormData{ files: map[string]string{ - "foo.TXT": "/tests/test/testdata/api/sample1.txt", + "foo.TXT": "testdata/sample.txt", }, }, filename: "foo.txt", defaultValue: "", - expect: "foo", + expect: "This is a text from a text file.", expectError: false, }, { scenario: "file does exist without a lowercase file extension", form: &FormData{ files: map[string]string{ - "foo.txt": "/tests/test/testdata/api/sample1.txt", + "foo.txt": "testdata/sample.txt", }, }, filename: "foo.txt", defaultValue: "", - expect: "foo", + expect: "This is a text from a text file.", expectError: false, }, } { @@ -1519,33 +1519,33 @@ func TestFormData_MandatoryContent(t *testing.T) { scenario: "mandatory file does exist without file extension", form: &FormData{ files: map[string]string{ - "foo": "/tests/test/testdata/api/sample1.txt", + "foo": "testdata/sample.txt", }, }, filename: "foo", - expect: "foo", + expect: "This is a text from a text file.", expectError: false, }, { scenario: "mandatory file does exist with an uppercase file extension", form: &FormData{ files: map[string]string{ - "foo.TXT": "/tests/test/testdata/api/sample1.txt", + "foo.TXT": "testdata/sample.txt", }, }, filename: "foo.txt", - expect: "foo", + expect: "This is a text from a text file.", expectError: false, }, { scenario: "mandatory file does exist without a lowercase file extension", form: &FormData{ files: map[string]string{ - "foo.txt": "/tests/test/testdata/api/sample1.txt", + "foo.txt": "testdata/sample.txt", }, }, filename: "foo.txt", - expect: "foo", + expect: "This is a text from a text file.", expectError: false, }, } { @@ -1612,6 +1612,24 @@ func TestFormData_Paths(t *testing.T) { }, expectCount: 2, }, + { + scenario: "files except embeds", + form: &FormData{ + files: map[string]string{ + "foo.pdf": "/foo.pdf", + "embed_1.pdf": "/embed_1.pdf", + "embed_2.xml": "/embed_2.xml", + }, + filesByField: map[string][]string{ + "embeds": {"/embed_1.pdf", "/embed_2.xml"}, + }, + }, + extensions: []string{".pdf"}, + expect: []string{ + "/foo.pdf", + }, + expectCount: 1, + }, } { t.Run(tc.scenario, func(t *testing.T) { var actual []string @@ -1740,3 +1758,28 @@ func TestFormData_mustAssign(t *testing.T) { var target []string form.mustAssign("foo", "foo", &target) } + +func TestFormData_Embeds(t *testing.T) { + expected := []string{"/bar.xml", "/baz.xml"} + + var actual []string + form := &FormData{ + files: map[string]string{ + "foo.pdf": "/foo.pdf", + "bar.xml": "/bar.xml", + "baz.xml": "/baz.xml", + }, + filesByField: map[string][]string{ + "embeds": {"/bar.xml", "/baz.xml"}, + }, + } + form.Embeds(&actual) + + if len(actual) != len(expected) { + t.Errorf("expected %d embeds but got %d", len(expected), len(actual)) + } + + if !reflect.DeepEqual(actual, expected) { + t.Errorf("expected %v but got %v", expected, actual) + } +} diff --git a/pkg/modules/api/middlewares.go b/pkg/modules/api/middlewares.go index c3939b5ac..a9149efed 100644 --- a/pkg/modules/api/middlewares.go +++ b/pkg/modules/api/middlewares.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "path/filepath" "strings" "time" @@ -48,6 +49,10 @@ func ParseError(err error) (int, string) { return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests) } + if errors.Is(err, gotenberg.ErrPdfSplitModeNotSupported) { + return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues" + } + if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) { return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues" } @@ -56,6 +61,11 @@ func ParseError(err error) (int, string) { return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues" } + var invalidArgsError *gotenberg.PdfEngineInvalidArgsError + if errors.As(err, &invalidArgsError) { + return http.StatusBadRequest, invalidArgsError.Error() + } + var httpErr HttpError if errors.As(err, &httpErr) { return httpErr.HttpError() @@ -82,7 +92,7 @@ func httpErrorHandler() echo.HTTPErrorHandler { } // latencyMiddleware sets the start time in the [echo.Context] under -// "startTime". Its value will be used later to calculate a request latency. +// "startTime". Its value will be used later to calculate request latency. // // startTime := c.Get("startTime").(time.Time) func latencyMiddleware() echo.MiddlewareFunc { @@ -105,7 +115,7 @@ func latencyMiddleware() echo.MiddlewareFunc { // rootPath := c.Get("rootPath").(string) // healthURI := fmt.Sprintf("%s/health", rootPath) // -// // Skip the middleware if health check URI. +// // Skip the middleware if it's the health check URI. // if c.Request().RequestURI == healthURI { // // Call the next middleware in the chain. // return next(c) @@ -146,6 +156,25 @@ func traceMiddleware(header string) echo.MiddlewareFunc { } } +// outputFilenameMiddleware sets the output filename in the [echo.Context] +// under "outputFilename". +// +// outputFilename := c.Get("outputFilename").(string) +func outputFilenameMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + filename := c.Request().Header.Get("Gotenberg-Output-Filename") + // See https://github.com/gotenberg/gotenberg/issues/1227. + if filename != "" { + filename = filepath.Base(filename) + } + c.Set("outputFilename", filename) + // Call the next middleware in the chain. + return next(c) + } + } +} + // loggerMiddleware sets the logger in the [echo.Context] under "logger" and // logs a synchronous request result. // @@ -157,9 +186,12 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo. trace := c.Get("trace").(string) rootPath := c.Get("rootPath").(string) - // Create the request logger and add it to our locals. - reqLogger := logger.With(zap.String("trace", trace)) - c.Set("logger", reqLogger.Named(func() string { + // Create the application logger and add it to our locals. + appLogger := logger. + With(zap.String("log_type", "application")). + With(zap.String("trace", trace)) + + c.Set("logger", appLogger.Named(func() string { return strings.ReplaceAll( strings.ReplaceAll(c.Request().URL.Path, rootPath, ""), "/", @@ -173,6 +205,11 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo. c.Error(err) } + // Create the access logger. + accessLogger := logger. + With(zap.String("log_type", "access")). + With(zap.String("trace", trace)) + for _, path := range disableLoggingForPaths { URI := fmt.Sprintf("%s%s", rootPath, path) @@ -208,9 +245,9 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo. fields[11] = zap.Int64("bytes_out", c.Response().Size) if err != nil { - reqLogger.Error(err.Error(), fields...) + accessLogger.Error(err.Error(), fields...) } else { - reqLogger.Info("request handled", fields...) + accessLogger.Info("request handled", fields...) } return nil @@ -229,7 +266,7 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc { }) } -// contextMiddleware, a middleware for "multipart/form-data" requests, sets the +// contextMiddleware, middleware for "multipart/form-data" requests, sets the // [Context] and related context.CancelFunc in the [echo.Context] under // "context" and "cancel". If the process is synchronous, it also handles the // result of a "multipart/form-data" request. @@ -244,7 +281,7 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimi trace := c.Get("trace").(string) // We create a context with a timeout so that underlying processes are - // able to stop early and handle correctly a timeout scenario. + // able to stop early and correctly handle a timeout scenario. ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg, traceHeader, trace) if err != nil { cancel() diff --git a/pkg/modules/api/middlewares_test.go b/pkg/modules/api/middlewares_test.go deleted file mode 100644 index 6edd1e2e4..000000000 --- a/pkg/modules/api/middlewares_test.go +++ /dev/null @@ -1,579 +0,0 @@ -package api - -import ( - "bytes" - "context" - "errors" - "mime/multipart" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/labstack/echo/v4" - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestParseError(t *testing.T) { - for i, tc := range []struct { - err error - expectStatus int - expectMessage string - }{ - { - err: echo.ErrInternalServerError, - expectStatus: http.StatusInternalServerError, - expectMessage: http.StatusText(http.StatusInternalServerError), - }, - { - err: gotenberg.ErrFiltered, - expectStatus: http.StatusForbidden, - expectMessage: http.StatusText(http.StatusForbidden), - }, - { - err: gotenberg.ErrMaximumQueueSizeExceeded, - expectStatus: http.StatusTooManyRequests, - expectMessage: http.StatusText(http.StatusTooManyRequests), - }, - { - err: gotenberg.ErrPdfFormatNotSupported, - expectStatus: http.StatusBadRequest, - expectMessage: "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues", - }, - { - err: gotenberg.ErrPdfEngineMetadataValueNotSupported, - expectStatus: http.StatusBadRequest, - expectMessage: "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues", - }, - { - err: WrapError( - errors.New("foo"), - NewSentinelHttpError(http.StatusBadRequest, "foo"), - ), - expectStatus: http.StatusBadRequest, - expectMessage: "foo", - }, - } { - actualStatus, actualMessage := ParseError(tc.err) - - if actualStatus != tc.expectStatus { - t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, actualStatus) - } - - if actualMessage != tc.expectMessage { - t.Errorf("test %d: expected message '%s' but got '%s'", i, tc.expectMessage, actualMessage) - } - } -} - -func TestHttpErrorHandler(t *testing.T) { - for i, tc := range []struct { - err error - expectStatus int - expectMessage string - }{ - { - err: echo.ErrInternalServerError, - expectStatus: http.StatusInternalServerError, - expectMessage: http.StatusText(http.StatusInternalServerError), - }, - { - err: context.DeadlineExceeded, - expectStatus: http.StatusServiceUnavailable, - expectMessage: http.StatusText(http.StatusServiceUnavailable), - }, - { - err: WrapError( - errors.New("foo"), - NewSentinelHttpError(http.StatusBadRequest, "foo"), - ), - expectStatus: http.StatusBadRequest, - expectMessage: "foo", - }, - } { - recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodGet, "/foo", nil) - - srv := echo.New() - srv.HideBanner = true - srv.HidePort = true - - c := srv.NewContext(request, recorder) - c.Set("logger", zap.NewNop()) - - handler := httpErrorHandler() - handler(tc.err, c) - - contentType := recorder.Header().Get(echo.HeaderContentType) - if contentType != echo.MIMETextPlainCharsetUTF8 { - t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8, contentType) - } - - // Note: we cannot test the trace header in the response here, as it is set in the trace middleware. - - if recorder.Code != tc.expectStatus { - t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, recorder.Code) - } - - if recorder.Body.String() != tc.expectMessage { - t.Errorf("test %d: expected message '%s' but got '%s'", i, tc.expectMessage, recorder.Body.String()) - } - } -} - -func TestLatencyMiddleware(t *testing.T) { - recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodGet, "/foo", nil) - - srv := echo.New() - srv.HideBanner = true - srv.HidePort = true - - c := srv.NewContext(request, recorder) - - err := latencyMiddleware()( - func(c echo.Context) error { - return nil - }, - )(c) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - startTime := c.Get("startTime").(time.Time) - now := time.Now() - - if now.Before(startTime) { - t.Errorf("expected start time %s to be < %s", startTime, now) - } -} - -func TestRootPathMiddleware(t *testing.T) { - recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodGet, "/foo", nil) - - srv := echo.New() - srv.HideBanner = true - srv.HidePort = true - - c := srv.NewContext(request, recorder) - - err := rootPathMiddleware("foo")( - func(c echo.Context) error { - return nil - }, - )(c) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - rootPath := c.Get("rootPath").(string) - - if rootPath != "foo" { - t.Errorf("expected '%s' but got '%s", "foo", rootPath) - } -} - -func TestTraceMiddleware(t *testing.T) { - for i, tc := range []struct { - trace string - }{ - { - trace: "foo", - }, - { - trace: "", - }, - } { - recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodGet, "/foo", nil) - - srv := echo.New() - srv.HideBanner = true - srv.HidePort = true - - c := srv.NewContext(request, recorder) - - if tc.trace != "" { - c.Request().Header.Set("Gotenberg-Trace", tc.trace) - } - - err := traceMiddleware("Gotenberg-Trace")( - func(c echo.Context) error { - return nil - }, - )(c) - if err != nil { - t.Fatalf("test %d: expected no error but got: %v", i, err) - } - - trace := c.Get("trace").(string) - - if trace == "" { - t.Errorf("test %d: expected non empty trace in context", i) - } - - if tc.trace != "" && trace != tc.trace { - t.Errorf("test %d: expected context trace '%s' but got '%s'", i, tc.trace, trace) - } - - if tc.trace == "" && trace == tc.trace { - t.Errorf("test %d: expected context trace different from '%s' but got '%s'", i, tc.trace, trace) - } - - responseTrace := recorder.Header().Get("Gotenberg-Trace") - - if tc.trace != "" && responseTrace != tc.trace { - t.Errorf("test %d: expected header trace '%s' but got '%s'", i, tc.trace, responseTrace) - } - - if tc.trace == "" && responseTrace == tc.trace { - t.Errorf("test %d: expected header trace different from '%s' but got '%s'", i, tc.trace, responseTrace) - } - } -} - -func TestBasicAuthMiddleware(t *testing.T) { - for _, tc := range []struct { - scenario string - request *http.Request - username string - password string - expectError bool - }{ - { - scenario: "invalid basic auth", - request: func() *http.Request { - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.SetBasicAuth("invalid", "invalid") - return req - }(), - username: "foo", - password: "bar", - expectError: true, - }, - { - scenario: "valid basic auth", - request: func() *http.Request { - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.SetBasicAuth("foo", "bar") - return req - }(), - username: "foo", - password: "bar", - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - recorder := httptest.NewRecorder() - srv := echo.New() - srv.HideBanner = true - srv.HidePort = true - c := srv.NewContext(tc.request, recorder) - err := basicAuthMiddleware(tc.username, tc.password)(func(c echo.Context) error { - return nil - })(c) - 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 TestLoggerMiddleware(t *testing.T) { - for i, tc := range []struct { - request *http.Request - next echo.HandlerFunc - skipLogging bool - }{ - { - request: httptest.NewRequest(http.MethodGet, "/", nil), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - return errors.New("foo") - } - }(), - }, - { - request: httptest.NewRequest(http.MethodGet, "/health", nil), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - return nil - } - }(), - skipLogging: true, - }, - { - request: httptest.NewRequest(http.MethodGet, "/health", nil), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - return nil - } - }(), - }, - } { - recorder := httptest.NewRecorder() - - srv := echo.New() - srv.HideBanner = true - srv.HidePort = true - - c := srv.NewContext(tc.request, recorder) - c.Set("startTime", time.Now()) - c.Set("trace", "foo") - c.Set("rootPath", "/") - - var disableLoggingForPaths []string - if tc.skipLogging { - disableLoggingForPaths = append(disableLoggingForPaths, tc.request.RequestURI) - } - - err := loggerMiddleware(zap.NewNop(), disableLoggingForPaths)(tc.next)(c) - if err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } - } -} - -func TestContextMiddleware(t *testing.T) { - buildMultipartFormDataRequest := func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - - err := writer.WriteField("foo", "foo") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - - return req - } - - for i, tc := range []struct { - request *http.Request - next echo.HandlerFunc - expectErr bool - expectStatus int - expectContentType string - expectFilename string - }{ - { - request: httptest.NewRequest(http.MethodGet, "/", nil), - expectErr: true, - }, - { - request: buildMultipartFormDataRequest(), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - return ErrAsyncProcess - } - }(), - expectStatus: http.StatusNoContent, - }, - { - request: buildMultipartFormDataRequest(), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - return ErrNoOutputFile - } - }(), - expectStatus: http.StatusOK, - }, - { - request: buildMultipartFormDataRequest(), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - return errors.New("foo") - } - }(), - expectErr: true, - }, - { - request: buildMultipartFormDataRequest(), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - return nil - } - }(), - expectErr: true, - }, - { - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Output-Filename", "foo") - - return req - }(), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - ctx := c.Get("context").(*Context) - ctx.outputPaths = []string{ - "/tests/test/testdata/api/sample2.pdf", - } - - return nil - } - }(), - expectStatus: http.StatusOK, - expectContentType: "application/pdf", - expectFilename: "foo.pdf", - }, - { - request: buildMultipartFormDataRequest(), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - ctx := c.Get("context").(*Context) - ctx.outputPaths = []string{ - "/tests/test/testdata/api/sample1.txt", - "/tests/test/testdata/api/sample2.pdf", - } - - return nil - } - }(), - expectStatus: http.StatusOK, - expectContentType: "application/zip", - }, - } { - recorder := httptest.NewRecorder() - - srv := echo.New() - srv.HideBanner = true - srv.HidePort = true - - c := srv.NewContext(tc.request, recorder) - c.Set("logger", zap.NewNop()) - c.Set("traceHeader", "Gotenberg-Trace") - c.Set("trace", "foo") - c.Set("startTime", time.Now()) - - err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second, 0, downloadFromConfig{})(tc.next)(c) - - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } - - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } - - if err != nil { - continue - } - - if recorder.Code != tc.expectStatus { - t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, recorder.Code) - } - - if tc.expectStatus == http.StatusNoContent { - continue - } - - contentType := recorder.Header().Get(echo.HeaderContentType) - if contentType != tc.expectContentType { - t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, tc.expectContentType, contentType) - } - - contentDisposition := recorder.Header().Get(echo.HeaderContentDisposition) - if !strings.Contains(contentDisposition, tc.expectFilename) { - t.Errorf("test %d: expected %s '%s' to contain '%s'", i, echo.HeaderContentDisposition, contentDisposition, tc.expectFilename) - } - } -} - -func TestHardTimeoutMiddleware(t *testing.T) { - for i, tc := range []struct { - next echo.HandlerFunc - timeout time.Duration - expectErr bool - expectHardTimeout bool - }{ - { - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - return nil - } - }(), - timeout: time.Duration(100) * time.Millisecond, - }, - { - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - panic("foo") - } - }(), - timeout: time.Duration(100) * time.Millisecond, - expectErr: true, - expectHardTimeout: true, - }, - { - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - return errors.New("foo") - } - }(), - timeout: time.Duration(100) * time.Millisecond, - expectErr: true, - }, - { - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - time.Sleep(time.Duration(200) * time.Millisecond) - - return nil - } - }(), - timeout: time.Duration(100) * time.Millisecond, - expectErr: true, - expectHardTimeout: true, - }, - } { - recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodGet, "/foo", nil) - - srv := echo.New() - srv.HideBanner = true - srv.HidePort = true - - c := srv.NewContext(request, recorder) - c.Set("logger", zap.NewNop()) - - err := hardTimeoutMiddleware(tc.timeout)(tc.next)(c) - - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } - - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } - - var isHardTimeout bool - if err != nil { - isHardTimeout = strings.Contains(err.Error(), "hard timeout") - } - - if tc.expectHardTimeout && !isHardTimeout { - t.Errorf("test %d: expected hard timeout error but got: %v", i, err) - } - - if !tc.expectHardTimeout && isHardTimeout { - t.Errorf("test %d: expected no hard timeout error but got one: %v", i, err) - } - } -} diff --git a/pkg/modules/api/mocks.go b/pkg/modules/api/mocks.go index 667cd14e9..7de02d643 100644 --- a/pkg/modules/api/mocks.go +++ b/pkg/modules/api/mocks.go @@ -54,7 +54,7 @@ func (ctx *ContextMock) SetFiles(files map[string]string) { ctx.files = files } -// SetCancelled sets if the context is cancelled or not. +// SetCancelled sets if the context is canceled or not. // // ctx := &api.ContextMock{Context: &api.Context{}} // ctx.SetCancelled(true) @@ -83,7 +83,15 @@ func (ctx *ContextMock) SetLogger(logger *zap.Logger) { // ctx := &api.ContextMock{Context: &api.Context{}} // ctx.setEchoContext(c) func (ctx *ContextMock) SetEchoContext(c echo.Context) { - ctx.Context.echoCtx = c + ctx.echoCtx = c +} + +// SetMkdirAll sets the [gotenberg.MkdirAll]. +// +// ctx := &api.ContextMock{Context: &api.Context{}} +// ctx.SetMkdirAll(mkdirAll) +func (ctx *ContextMock) SetMkdirAll(mkdirAll gotenberg.MkdirAll) { + ctx.mkdirAll = mkdirAll } // SetPathRename sets the [gotenberg.PathRename]. @@ -91,7 +99,7 @@ func (ctx *ContextMock) SetEchoContext(c echo.Context) { // ctx := &api.ContextMock{Context: &api.Context{}} // ctx.setPathRename(rename) func (ctx *ContextMock) SetPathRename(rename gotenberg.PathRename) { - ctx.Context.pathRename = rename + ctx.pathRename = rename } // RouterMock is a mock for the [Router] interface. @@ -112,7 +120,7 @@ func (provider *MiddlewareProviderMock) Middlewares() ([]Middleware, error) { return provider.MiddlewaresMock() } -// HealthCheckerMock is mock for the [HealthChecker] interface. +// HealthCheckerMock is a mock for the [HealthChecker] interface. type HealthCheckerMock struct { ChecksMock func() ([]health.CheckerOption, error) ReadyMock func() error diff --git a/pkg/modules/api/mocks_test.go b/pkg/modules/api/mocks_test.go deleted file mode 100644 index 5910726c2..000000000 --- a/pkg/modules/api/mocks_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package api - -import ( - "reflect" - "testing" - - "github.com/alexliesenfeld/health" - "github.com/labstack/echo/v4" - "go.uber.org/zap" -) - -func TestContextMock_SetDirPath(t *testing.T) { - mock := &ContextMock{&Context{}} - mock.SetDirPath("/foo") - - actual := mock.dirPath - expect := "/foo" - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestContextMock_DirPath(t *testing.T) { - mock := &ContextMock{&Context{}} - mock.SetDirPath("/foo") - - actual := mock.DirPath() - expect := "/foo" - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestContextMock_SetValues(t *testing.T) { - mock := &ContextMock{&Context{}} - mock.SetValues(map[string][]string{ - "foo": {"foo"}, - }) - - actual := mock.values - expect := map[string][]string{ - "foo": {"foo"}, - } - - if !reflect.DeepEqual(actual, expect) { - t.Errorf("expected %+v but got: %+v", expect, actual) - } -} - -func TestContextMock_SetFiles(t *testing.T) { - mock := &ContextMock{&Context{}} - mock.SetFiles(map[string]string{ - "foo": "/foo", - }) - - actual := mock.files - expect := map[string]string{ - "foo": "/foo", - } - - if !reflect.DeepEqual(actual, expect) { - t.Errorf("expected %+v but got: %+v", expect, actual) - } -} - -func TestContextMock_SetCancelled(t *testing.T) { - mock := &ContextMock{&Context{}} - mock.SetCancelled(true) - - actual := mock.cancelled - - if !actual { - t.Errorf("expected %t but got %t", true, actual) - } -} - -func TestContextMock_OutputPaths(t *testing.T) { - mock := ContextMock{ - &Context{ - outputPaths: []string{"/foo"}, - }, - } - - actual := mock.OutputPaths() - expect := []string{"/foo"} - - if !reflect.DeepEqual(actual, expect) { - t.Errorf("expected %+v but got: %+v", expect, actual) - } -} - -func TestContextMock_SetLogger(t *testing.T) { - mock := ContextMock{&Context{}} - - expect := zap.NewNop() - mock.SetLogger(expect) - - actual := mock.logger - - if actual != expect { - t.Errorf("expected %v but got %v", expect, actual) - } -} - -func TestContextMock_SetEchoContext(t *testing.T) { - mock := ContextMock{&Context{}} - - expect := echo.New().NewContext(nil, nil) - mock.SetEchoContext(expect) - - actual := mock.echoCtx - - if actual != expect { - t.Errorf("expected %v but got %v", expect, actual) - } -} - -func TestContextMock_SetPathRename(t *testing.T) { - mock := ContextMock{&Context{}} - - expect := new(osPathRename) - mock.SetPathRename(expect) - - actual := mock.pathRename - - if actual != expect { - t.Errorf("expected %v but got %v", expect, actual) - } -} - -func TestRouterMock(t *testing.T) { - mock := &RouterMock{ - RoutesMock: func() ([]Route, error) { - return nil, nil - }, - } - - _, err := mock.Routes() - if err != nil { - t.Errorf("expected no error from RouterMock.Routes, but got: %v", err) - } -} - -func TestMiddlewareProviderMock(t *testing.T) { - mock := &MiddlewareProviderMock{ - MiddlewaresMock: func() ([]Middleware, error) { - return nil, nil - }, - } - - _, err := mock.Middlewares() - if err != nil { - t.Errorf("expected no error from MiddlewareProviderMock.Middlewares, but got: %v", err) - } -} - -func TestHealthCheckerMock(t *testing.T) { - mock := &HealthCheckerMock{ - ChecksMock: func() ([]health.CheckerOption, error) { - return nil, nil - }, - ReadyMock: func() error { - return nil - }, - } - - _, err := mock.Checks() - if err != nil { - t.Errorf("expected no error from HealthCheckerMock.Checks, but got: %v", err) - } - - err = mock.Ready() - if err != nil { - t.Errorf("expected no error from HealthCheckerMock.Ready, but got: %v", err) - } -} diff --git a/test/testdata/libreoffice/document.txt b/pkg/modules/api/testdata/sample.txt similarity index 100% rename from test/testdata/libreoffice/document.txt rename to pkg/modules/api/testdata/sample.txt diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index 380dcccfb..8f4a5f64a 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -10,11 +10,14 @@ import ( "sync/atomic" "time" + cdprotobrowser "github.com/chromedp/cdproto/browser" "github.com/chromedp/cdproto/fetch" "github.com/chromedp/cdproto/network" + "github.com/chromedp/cdproto/page" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "github.com/dlclark/regexp2" + "github.com/shirou/gopsutil/v4/process" "go.uber.org/zap" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" @@ -29,7 +32,6 @@ type browser interface { type browserArguments struct { // Executor args. binPath string - incognito bool allowInsecureLocalhost bool ignoreCertificateErrors bool disableWebSecurity bool @@ -37,6 +39,7 @@ type browserArguments struct { hostResolverRules string proxyServer string wsUrlReadTimeout time.Duration + hyphenDataDirPath string // Tasks specific. allowList *regexp2.Regexp @@ -62,7 +65,7 @@ func newChromiumBrowser(arguments browserArguments) browser { b := &chromiumBrowser{ initialCtx: context.Background(), arguments: arguments, - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), } b.isStarted.Store(false) @@ -77,27 +80,34 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error { debug := &debugLogger{logger: logger} b.userProfileDirPath = b.fs.NewDirPath() + // See https://github.com/gotenberg/gotenberg/issues/1293. + err := os.MkdirAll(b.userProfileDirPath, 0o755) + if err != nil { + return fmt.Errorf("could not create user profile directory: %w", err) + } + err = os.Symlink(b.arguments.hyphenDataDirPath, fmt.Sprintf("%s/hyphen-data", b.userProfileDirPath)) + if err != nil { + return fmt.Errorf("create symlink to hyphen-data directory: %w", err) + } + opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.CombinedOutput(debug), chromedp.ExecPath(b.arguments.binPath), chromedp.NoSandbox, // See: - // https://github.com/gotenberg/gotenberg/issues/327 - // https://github.com/chromedp/chromedp/issues/904 - chromedp.DisableGPU, - // See: // https://github.com/puppeteer/puppeteer/issues/661 // https://github.com/puppeteer/puppeteer/issues/2410 chromedp.Flag("font-render-hinting", "none"), chromedp.UserDataDir(b.userProfileDirPath), // See https://github.com/gotenberg/gotenberg/issues/831. chromedp.Flag("disable-pdf-tagging", true), + // See https://github.com/gotenberg/gotenberg/issues/1177. + chromedp.Flag("no-zygote", true), + chromedp.Flag("disable-dev-shm-usage", true), + // See https://github.com/gotenberg/gotenberg/issues/1293. + chromedp.Flag("disable-component-update", false), ) - if b.arguments.incognito { - opts = append(opts, chromedp.Flag("incognito", b.arguments.incognito)) - } - if b.arguments.allowInsecureLocalhost { // See https://github.com/gotenberg/gotenberg/issues/488. opts = append(opts, chromedp.Flag("allow-insecure-localhost", true)) @@ -132,7 +142,7 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error { allocatorCtx, allocatorCancel := chromedp.NewExecAllocator(b.initialCtx, opts...) ctx, cancel := chromedp.NewContext(allocatorCtx, chromedp.WithDebugf(debug.Printf)) - err := chromedp.Run(ctx) + err = chromedp.Run(ctx) if err != nil { cancel() allocatorCancel() @@ -162,21 +172,61 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error { // Always remove the user profile directory created by Chromium. copyUserProfileDirPath := b.userProfileDirPath - defer func(userProfileDirPath string) { + expirationTime := time.Now() + defer func(userProfileDirPath string, expirationTime time.Time) { + // See: + // https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChrome/chrome-cleanup.sh + // https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChromium/chrome-cleanup.sh + + // Clean up stuck processes. + ps, err := process.Processes() + if err != nil { + logger.Error(fmt.Sprintf("list processes: %v", err)) + } else { + for _, p := range ps { + func() { + cmdline, err := p.Cmdline() + if err != nil { + return + } + + if !strings.Contains(cmdline, "chromium/chromium") && !strings.Contains(cmdline, "chrome/chrome") { + return + } + + killCtx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + err = p.KillWithContext(killCtx) + if err != nil { + logger.Error(fmt.Sprintf("kill process: %v", err)) + } else { + logger.Debug(fmt.Sprintf("Chromium process %d killed", p.Pid)) + } + }() + } + } + go func() { // FIXME: Chromium seems to recreate the user profile directory // right after its deletion if we do not wait a certain amount // of time before deleting it. <-time.After(10 * time.Second) - err := os.RemoveAll(userProfileDirPath) + err = os.RemoveAll(userProfileDirPath) if err != nil { logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err)) + } else { + logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath)) } - logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath)) + // Also, remove Chromium-specific files in the temporary directory. + err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime) + if err != nil { + logger.Error(err.Error()) + } }() - }(copyUserProfileDirPath) + }(copyUserProfileDirPath, expirationTime) b.ctxMu.Lock() defer b.ctxMu.Unlock() @@ -201,13 +251,20 @@ func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool { b.ctxMu.RLock() defer b.ctxMu.RUnlock() - timeoutCtx, timeoutCancel := context.WithTimeout(b.ctx, time.Duration(10)*time.Second) - defer timeoutCancel() - - taskCtx, taskCancel := chromedp.NewContext(timeoutCtx) - defer taskCancel() - - err := chromedp.Run(taskCtx, chromedp.Navigate("about:blank")) + // Create a timeout based on the existing browser context (b.ctx). + // IMPORTANT: We do NOT call chromedp.NewContext here. + // We want to execute this against the main browser connection, + // avoiding the creation of a new target (tab). + ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second) + defer cancel() + + // Check if the browser is responsive by asking for its version. + // This involves a simple JSON payload roundtrip over the websocket. + // See https://github.com/gotenberg/gotenberg/issues/1169. + err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + _, _, _, _, _, err := cdprotobrowser.GetVersion().Do(ctx) + return err + })) if err != nil { logger.Error(fmt.Sprintf("browser health check failed: %s", err)) return false @@ -230,12 +287,14 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp userAgentOverride(logger, options.UserAgent), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground), - forceExactColorsActionFunc(), + forceExactColorsActionFunc(logger, options.PrintBackground), emulateMediaTypeActionFunc(logger, options.EmulatedMediaType), - waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression), + waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), // PDF specific. printToPdfActionFunc(logger, outputPath, options), + // Teardown. + page.Close(), }) } @@ -253,13 +312,15 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur userAgentOverride(logger, options.UserAgent), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true), - forceExactColorsActionFunc(), + forceExactColorsActionFunc(logger, true), emulateMediaTypeActionFunc(logger, options.EmulatedMediaType), - waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression), + waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), // Screenshot specific. setDeviceMetricsOverride(logger, options.Width, options.Height), captureScreenshotActionFunc(logger, outputPath, options), + // Teardown. + page.Close(), }) } @@ -273,7 +334,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string return errors.New("context has no deadline") } - // We validate the "main" URL against our allow / deny lists. + // We validate the "main" URL against our allowed / deny lists. err := gotenberg.FilterDeadline(b.arguments.allowList, b.arguments.denyList, url, deadline) if err != nil { return fmt.Errorf("filter URL: %w", err) @@ -288,7 +349,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string taskCtx, taskCancel := chromedp.NewContext(timeoutCtx) defer taskCancel() - // We validate all others requests against our allow / deny lists. + // We validate all other requests against our allowed / deny lists. // If a request does not pass the validation, we make it fail. It also set // the extra HTTP headers, if any. // See https://github.com/gotenberg/gotenberg/issues/1011. @@ -352,6 +413,10 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string if err != nil { errMessage := err.Error() + if strings.Contains(errMessage, "Printing failed (-32000)") { + return ErrPrintingFailed + } + if strings.Contains(errMessage, "Show invalid printer settings error (-32000)") || strings.Contains(errMessage, "content area is empty (-32602)") { return ErrInvalidPrinterSettings } @@ -360,6 +425,10 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string return ErrPageRangesSyntaxError } + if strings.Contains(errMessage, "Page range exceeds page count (-32000)") { + return ErrPageRangesExceedsPageCount + } + if strings.Contains(errMessage, "rpcc: message too large") { return ErrRpccMessageTooLarge } diff --git a/pkg/modules/chromium/browser_test.go b/pkg/modules/chromium/browser_test.go deleted file mode 100644 index 3ba608b4b..000000000 --- a/pkg/modules/chromium/browser_test.go +++ /dev/null @@ -1,2484 +0,0 @@ -package chromium - -import ( - "context" - "errors" - "fmt" - "os" - "strings" - "testing" - "time" - - "github.com/dlclark/regexp2" - "github.com/google/uuid" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "go.uber.org/zap/zaptest/observer" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestChromiumBrowser_Start(t *testing.T) { - for _, tc := range []struct { - scenario string - browser browser - expectError bool - cleanup bool - }{ - { - scenario: "successful start", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - }, - ), - expectError: false, - cleanup: true, - }, - { - scenario: "all browser arguments", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - incognito: true, - allowInsecureLocalhost: true, - ignoreCertificateErrors: true, - disableWebSecurity: true, - allowFileAccessFromFiles: true, - hostResolverRules: "MAP forgery.docker.localhost traefik", - proxyServer: "1.2.3.4", - }, - ), - expectError: false, - cleanup: true, - }, - { - scenario: "browser already started", - browser: func() browser { - b := new(chromiumBrowser) - b.isStarted.Store(true) - return b - }(), - expectError: true, - cleanup: false, - }, - { - scenario: "browser start error", - browser: func() browser { - b := newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - }, - ).(*chromiumBrowser) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - b.initialCtx = ctx - - return b - }(), - expectError: true, - cleanup: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() - err := tc.browser.Start(logger) - - if tc.cleanup { - defer func(b browser, logger *zap.Logger) { - err = b.Stop(logger) - if err != nil { - t.Fatalf("expected no error while cleaning up, but got: %v", err) - } - }(tc.browser, logger) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestChromiumBrowser_Stop(t *testing.T) { - for _, tc := range []struct { - scenario string - browser browser - setup func(browser browser, logger *zap.Logger) error - expectError bool - }{ - { - scenario: "successful stop", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - }, - ), - setup: func(b browser, logger *zap.Logger) error { - return b.Start(logger) - }, - expectError: false, - }, - { - scenario: "browser already stopped", - browser: func() browser { - b := new(chromiumBrowser) - b.isStarted.Store(false) - return b - }(), - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() - - if tc.setup != nil { - err := tc.setup(tc.browser, logger) - if err != nil { - t.Fatalf("setup error: %v", err) - } - } - - err := tc.browser.Stop(logger) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestChromiumBrowser_Healthy(t *testing.T) { - for _, tc := range []struct { - scenario string - browser browser - setup func(browser browser, logger *zap.Logger) error - expectHealthy bool - cleanup bool - }{ - { - scenario: "healthy browser", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - }, - ), - setup: func(b browser, logger *zap.Logger) error { - return b.Start(logger) - }, - expectHealthy: true, - cleanup: true, - }, - { - scenario: "browser not started", - browser: func() browser { - b := new(chromiumBrowser) - b.isStarted.Store(false) - return b - }(), - expectHealthy: false, - cleanup: false, - }, - { - scenario: "unhealthy browser", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - }, - ), - setup: func(b browser, logger *zap.Logger) error { - _ = b.Start(logger) - b.(*chromiumBrowser).cancelFunc() - - return nil - }, - expectHealthy: false, - cleanup: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() - - if tc.setup != nil { - err := tc.setup(tc.browser, logger) - if err != nil { - t.Fatalf("setup error: %v", err) - } - } - - if tc.cleanup { - defer func(b browser, logger *zap.Logger) { - err := b.Stop(logger) - if err != nil { - t.Fatalf("expected no error while cleaning up, but got: %v", err) - } - }(tc.browser, logger) - } - - healthy := tc.browser.Healthy(logger) - - if !tc.expectHealthy && healthy { - t.Fatal("expected unhealthy browser but got an healthy one") - } - - if tc.expectHealthy && !healthy { - t.Fatal("expected a healthy browser but got an unhealthy one") - } - }) - } -} - -func TestChromiumBrowser_pdf(t *testing.T) { - for _, tc := range []struct { - scenario string - browser browser - fs *gotenberg.FileSystem - options PdfOptions - url string - noDeadline bool - start bool - expectError bool - expectedError error - expectedLogEntries []string - }{ - { - scenario: "browser not started", - browser: func() browser { - b := new(chromiumBrowser) - b.isStarted.Store(false) - return b - }(), - fs: gotenberg.NewFileSystem(), - noDeadline: false, - start: false, - expectError: true, - }, - { - scenario: "context has no deadline", - browser: func() browser { - b := new(chromiumBrowser) - b.isStarted.Store(true) - return b - }(), - fs: gotenberg.NewFileSystem(), - noDeadline: true, - start: false, - expectError: true, - }, - { - scenario: "ErrFiltered: main URL does not match the allowed list", - browser: func() browser { - b := new(chromiumBrowser) - b.arguments = browserArguments{ - allowList: regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0), - denyList: regexp2.MustCompile("", 0), - } - b.isStarted.Store(true) - return b - }(), - fs: gotenberg.NewFileSystem(), - noDeadline: false, - start: false, - expectError: true, - expectedError: gotenberg.ErrFiltered, - }, - { - scenario: "ErrFiltered: main URL does match the denied list", - browser: func() browser { - b := new(chromiumBrowser) - b.arguments = browserArguments{ - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("^file:///tmp.*", 0), - } - b.isStarted.Store(true) - return b - }(), - fs: gotenberg.NewFileSystem(), - noDeadline: false, - start: false, - expectError: true, - expectedError: gotenberg.ErrFiltered, - }, - { - scenario: "a request does not match the allowed list", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("^file:///tmp.*", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "'file:///etc/passwd' does not match the expression from the allowed list", - }, - }, - { - scenario: "a request does match the denied list", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "'file:///etc/passwd' matches the expression from the denied list", - }, - }, - { - scenario: "do not skip networkIdle event", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Skip networkIdle event

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{SkipNetworkIdleEvent: false}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "event networkIdle fired", - }, - }, - { - scenario: "ErrInvalidHttpStatusCode", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrInvalidHttpStatusCode

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{FailOnHttpStatusCodes: []int64{299}}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrInvalidHttpStatusCode, - }, - { - scenario: "ErrInvalidResourceHttpStatusCode", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/style.css", fs.WorkingDirPath()), []byte("body{font-family: Arial, Helvetica, sans-serif;}"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrInvalidResourceHttpStatusCode

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{FailOnResourceHttpStatusCodes: []int64{200}}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrInvalidResourceHttpStatusCode, - }, - { - scenario: "ErrConsoleExceptions", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{FailOnConsoleExceptions: true}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrConsoleExceptions, - }, - { - scenario: "ErrLoadingFailed", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - return fs - }(), - url: "http://localhost:100", - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrLoadingFailed, - }, - { - scenario: "ErrResourceLoadingFailed", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrResourceLoadingFailed

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{FailOnResourceLoadingFailed: true}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrResourceLoadingFailed, - }, - { - scenario: "clear cache", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - clearCache: true, - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Clear cache

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "clear cache", - }, - }, - { - scenario: "clear cookies", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - clearCookies: true, - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Clear cookies

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "clear cookies", - }, - }, - { - scenario: "disable JavaScript", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - disableJavaScript: true, - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "disable JavaScript", - "JavaScript disabled, skipping wait delay", - "JavaScript disabled, skipping wait expression", - }, - }, - { - scenario: "set cookies", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Set cookies

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{Cookies: []Cookie{{Name: "foo", Value: "bar", Domain: ".foo.bar"}}}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "set cookie", - }, - }, - { - scenario: "user agent override", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

User-Agent override

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{UserAgent: "foo"}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - fmt.Sprintf("user agent override: foo"), - }, - }, - { - scenario: "extra HTTP headers", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Extra HTTP headers

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{ExtraHttpHeaders: []ExtraHttpHeader{ - { - Name: "X-Foo", - Value: "foo", - }, - { - Name: "X-Bar", - Value: "bar", - Scope: regexp2.MustCompile(`.*index\.html.*`, 0), - }, - { - Name: "X-Baz", - Value: "baz", - Scope: regexp2.MustCompile(`.*another\.html.*`, 0), - }, - }}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "extra HTTP headers:", - "extra HTTP header 'X-Foo' will be set for request URL", - "extra HTTP header 'X-Bar' (scoped) will be set for request URL", - "extra HTTP header 'X-Baz' (scoped) will not be set for request URL", - "setting extra HTTP headers for request URL", - }, - }, - { - scenario: "ErrOmitBackgroundWithoutPrintBackground", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrOmitBackgroundWithoutPrintBackground

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{OmitBackground: true}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrOmitBackgroundWithoutPrintBackground, - }, - { - scenario: "hide default white background", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Hide default white background

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{OmitBackground: true}, - PrintBackground: true, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "hide default white background", - }, - }, - { - scenario: "ErrInvalidEmulatedMediaType", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrInvalidEmulatedMediaType

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{EmulatedMediaType: "foo"}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrInvalidEmulatedMediaType, - }, - { - scenario: "emulate a media type", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Screen media type

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{EmulatedMediaType: "screen"}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "emulate media type 'screen'", - }, - }, - { - scenario: "wait delay: context done", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{WaitDelay: time.Duration(10) * time.Second}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedLogEntries: []string{ - "wait '10s' before print", - }, - }, - { - scenario: "wait delay", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{WaitDelay: time.Duration(1) * time.Millisecond}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "wait '1ms' before print", - }, - }, - { - scenario: "wait for expression: context done", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - html := ` - -` - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(html), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{WaitForExpression: "window.status === 'ready'"}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedLogEntries: []string{ - "wait until 'window.status === 'ready'' is true before print", - }, - }, - { - scenario: "ErrInvalidEvaluationExpression", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrInvalidEvaluationExpression

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{WaitForExpression: "return undefined"}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedLogEntries: []string{ - "wait until 'return undefined' is true before print", - }, - }, - { - scenario: "wait for expression", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - html := ` - -` - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(html), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - Options: Options{WaitForExpression: "window.globalVar === 'ready'"}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "wait until 'window.globalVar === 'ready'' is true before print", - }, - }, - { - scenario: "single page", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Custom header and footer

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - SinglePage: true, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "single page PDF", - }, - }, - { - scenario: "custom header and footer", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Custom header and footer

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - HeaderTemplate: "

Header

", - FooterTemplate: "

Footer

", - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "with custom header and/or footer", - }, - }, - { - scenario: "ErrInvalidPrinterSettings", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrInvalidPrinterSettings

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - PaperWidth: 0, - PaperHeight: 0, - MarginTop: 1000000, - MarginBottom: 1000000, - MarginLeft: 1000000, - MarginRight: 1000000, - }, - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrInvalidPrinterSettings, - }, - { - scenario: "ErrPageRangesSyntaxError", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrPageRangesSyntaxError

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: PdfOptions{ - PageRanges: "foo", - }, - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrPageRangesSyntaxError, - }, - { - scenario: "success (default options)", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Default options

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: DefaultPdfOptions(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "cache not cleared", - "cookies not cleared", - "JavaScript not disabled", - "no cookies to set", - "no extra HTTP headers", - "navigate to", - "skipping network idle event", - "default white background not hidden", - "no emulated media type", - "no wait delay", - "no wait expression", - "no custom header nor footer", - }, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - core, recorded := observer.New(zapcore.DebugLevel) - logger := zap.New(core) - - defer func() { - err := os.RemoveAll(tc.fs.WorkingDirPath()) - if err != nil { - t.Fatalf("expected no error while cleaning up, but got: %v", err) - } - }() - - if tc.start { - err := tc.browser.Start(logger) - if err != nil { - t.Fatalf("setup error: %v", err) - } - - defer func(b browser, logger *zap.Logger) { - err = b.Stop(logger) - if err != nil { - t.Fatalf("expected no error while cleaning up, but got: %v", err) - } - }(tc.browser, logger) - } - - var ( - ctx context.Context - cancel context.CancelFunc - ) - - if tc.noDeadline { - ctx = context.Background() - } else { - ctx, cancel = context.WithTimeout(context.Background(), time.Duration(5)*time.Second) - defer cancel() - } - - url := fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath()) - if tc.url != "" { - url = tc.url - } - - err := tc.browser.pdf( - ctx, - logger, - url, - fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()), - tc.options, - ) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - - if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { - t.Fatalf("expected error %v but got: %v", tc.expectedError, err) - } - - for _, entry := range tc.expectedLogEntries { - doExist := true - for _, log := range recorded.All() { - doExist = strings.Contains(log.Message, entry) - if doExist { - break - } - } - - if !doExist { - t.Errorf("expected '%s' to exist as log entry", entry) - } - } - }) - } -} - -func TestChromiumBrowser_screenshot(t *testing.T) { - for _, tc := range []struct { - scenario string - browser browser - fs *gotenberg.FileSystem - options ScreenshotOptions - url string - noDeadline bool - start bool - expectError bool - expectedError error - expectedLogEntries []string - }{ - { - scenario: "browser not started", - browser: func() browser { - b := new(chromiumBrowser) - b.isStarted.Store(false) - return b - }(), - fs: gotenberg.NewFileSystem(), - noDeadline: false, - start: false, - expectError: true, - }, - { - scenario: "context has not deadline", - browser: func() browser { - b := new(chromiumBrowser) - b.arguments = browserArguments{ - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - } - b.isStarted.Store(true) - return b - }(), - fs: gotenberg.NewFileSystem(), - noDeadline: true, - start: false, - expectError: true, - }, - { - scenario: "ErrFiltered: main URL does not match the allowed list", - browser: func() browser { - b := new(chromiumBrowser) - b.arguments = browserArguments{ - allowList: regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0), - denyList: regexp2.MustCompile("", 0), - } - b.isStarted.Store(true) - return b - }(), - fs: gotenberg.NewFileSystem(), - noDeadline: false, - start: false, - expectError: true, - expectedError: gotenberg.ErrFiltered, - }, - { - scenario: "ErrFiltered: main URL does match the denied list", - browser: func() browser { - b := new(chromiumBrowser) - b.arguments = browserArguments{ - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("^file:///tmp.*", 0), - } - b.isStarted.Store(true) - return b - }(), - fs: gotenberg.NewFileSystem(), - noDeadline: false, - start: false, - expectError: true, - expectedError: gotenberg.ErrFiltered, - }, - { - scenario: "a request does not match the allowed list", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("^file:///tmp.*", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "'file:///etc/passwd' does not match the expression from the allowed list", - }, - }, - { - scenario: "a request does match the denied list", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "'file:///etc/passwd' matches the expression from the denied list", - }, - }, - { - scenario: "do not skip networkIdle event", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Skip networkIdle event

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{SkipNetworkIdleEvent: false}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "event networkIdle fired", - }, - }, - { - scenario: "ErrInvalidHttpStatusCode", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrInvalidHttpStatusCode

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{FailOnHttpStatusCodes: []int64{299}}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrInvalidHttpStatusCode, - }, - { - scenario: "ErrInvalidResourceHttpStatusCode", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/style.css", fs.WorkingDirPath()), []byte("body{font-family: Arial, Helvetica, sans-serif;}"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrInvalidResourceHttpStatusCode

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{FailOnResourceHttpStatusCodes: []int64{299}}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrInvalidResourceHttpStatusCode, - }, - { - scenario: "ErrConsoleExceptions", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{FailOnConsoleExceptions: true}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrConsoleExceptions, - }, - { - scenario: "ErrLoadingFailed", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - return fs - }(), - url: "http://localhost:100", - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrLoadingFailed, - }, - { - scenario: "ErrResourceLoadingFailed", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrResourceLoadingFailed

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{FailOnResourceLoadingFailed: true}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrResourceLoadingFailed, - }, - { - scenario: "clear cache", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - clearCache: true, - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Clear cache

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "clear cache", - }, - }, - { - scenario: "clear cookies", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - clearCookies: true, - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Clear cookies

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "clear cookies", - }, - }, - { - scenario: "disable JavaScript", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - disableJavaScript: true, - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "disable JavaScript", - "JavaScript disabled, skipping wait delay", - "JavaScript disabled, skipping wait expression", - }, - }, - { - scenario: "set cookies", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Set cookies

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{Cookies: []Cookie{{Name: "fpp", Value: "bar", Domain: ".foo.bar"}}}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "set cookie", - }, - }, - { - scenario: "user agent override", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

User-Agent override

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{UserAgent: "foo"}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - fmt.Sprintf("user agent override: foo"), - }, - }, - { - scenario: "extra HTTP headers", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Extra HTTP headers

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{ExtraHttpHeaders: []ExtraHttpHeader{ - { - Name: "X-Foo", - Value: "foo", - }, - { - Name: "X-Bar", - Value: "bar", - Scope: regexp2.MustCompile(`.*index\.html.*`, 0), - }, - { - Name: "X-Baz", - Value: "baz", - Scope: regexp2.MustCompile(`.*another\.html.*`, 0), - }, - }}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "extra HTTP headers:", - "extra HTTP header 'X-Foo' will be set for request URL", - "extra HTTP header 'X-Bar' (scoped) will be set for request URL", - "extra HTTP header 'X-Baz' (scoped) will not be set for request URL", - "setting extra HTTP headers for request URL", - }, - }, - { - scenario: "ErrOmitBackgroundWithoutPrintBackground", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrOmitBackgroundWithoutPrintBackground

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{OmitBackground: true}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "hide default white background", - }, - }, - { - scenario: "ErrInvalidEmulatedMediaType", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrInvalidEmulatedMediaType

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{EmulatedMediaType: "foo"}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedError: ErrInvalidEmulatedMediaType, - }, - { - scenario: "emulate a media type", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Screen media type

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{EmulatedMediaType: "screen"}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "emulate media type 'screen'", - }, - }, - { - scenario: "wait delay: context done", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{WaitDelay: time.Duration(10) * time.Second}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedLogEntries: []string{ - "wait '10s' before print", - }, - }, - { - scenario: "wait delay", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{WaitDelay: time.Duration(1) * time.Millisecond}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "wait '1ms' before print", - }, - }, - { - scenario: "wait for expression: context done", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - html := ` - -` - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(html), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{WaitForExpression: "window.status === 'ready'"}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedLogEntries: []string{ - "wait until 'window.status === 'ready'' is true before print", - }, - }, - { - scenario: "ErrInvalidEvaluationExpression", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrInvalidEvaluationExpression

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{WaitForExpression: "return undefined"}, - }, - noDeadline: false, - start: true, - expectError: true, - expectedLogEntries: []string{ - "wait until 'return undefined' is true before print", - }, - }, - { - scenario: "wait for expression", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - html := ` - -` - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(html), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: ScreenshotOptions{ - Options: Options{WaitForExpression: "window.globalVar === 'ready'"}, - }, - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "wait until 'window.globalVar === 'ready'' is true before print", - }, - }, - { - scenario: "success (default options)", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Default options

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: DefaultScreenshotOptions(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "cache not cleared", - "cookies not cleared", - "JavaScript not disabled", - "no user agent override", - "no extra HTTP headers", - "navigate to", - "default white background not hidden", - "no emulated media type", - "no wait delay", - "no wait expression", - "set device metrics override", - }, - }, - { - scenario: "success (clip)", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Default options

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: func() ScreenshotOptions { - options := DefaultScreenshotOptions() - options.Clip = true - return options - }(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "cache not cleared", - "cookies not cleared", - "JavaScript not disabled", - "no cookies to set", - "no user agent override", - "no extra HTTP headers", - "navigate to", - "default white background not hidden", - "no emulated media type", - "no wait delay", - "no wait expression", - "set device metrics override", - }, - }, - { - scenario: "success (jpeg)", - browser: newChromiumBrowser( - browserArguments{ - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - wsUrlReadTimeout: 5 * time.Second, - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Default options

"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: func() ScreenshotOptions { - options := DefaultScreenshotOptions() - options.Format = "jpeg" - return options - }(), - noDeadline: false, - start: true, - expectError: false, - expectedLogEntries: []string{ - "cache not cleared", - "cookies not cleared", - "JavaScript not disabled", - "no cookies to set", - "no user agent override", - "no extra HTTP headers", - "navigate to", - "skipping network idle event", - "default white background not hidden", - "no emulated media type", - "no wait delay", - "no wait expression", - "set device metrics override", - }, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - core, recorded := observer.New(zapcore.DebugLevel) - logger := zap.New(core) - - defer func() { - err := os.RemoveAll(tc.fs.WorkingDirPath()) - if err != nil { - t.Fatalf("expected no error while cleaning up, but got: %v", err) - } - }() - - if tc.start { - err := tc.browser.Start(logger) - if err != nil { - t.Fatalf("setup error: %v", err) - } - - defer func(b browser, logger *zap.Logger) { - err = b.Stop(logger) - if err != nil { - t.Fatalf("expected no error while cleaning up, but got: %v", err) - } - }(tc.browser, logger) - } - - var ( - ctx context.Context - cancel context.CancelFunc - ) - - if tc.noDeadline { - ctx = context.Background() - } else { - ctx, cancel = context.WithTimeout(context.Background(), time.Duration(5)*time.Second) - defer cancel() - } - - url := fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath()) - if tc.url != "" { - url = tc.url - } - - err := tc.browser.screenshot( - ctx, - logger, - url, - fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()), - tc.options, - ) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - - if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { - t.Fatalf("expected error %v but got: %v", tc.expectedError, err) - } - - for _, entry := range tc.expectedLogEntries { - doExist := true - for _, log := range recorded.All() { - doExist = strings.Contains(log.Message, entry) - if doExist { - break - } - } - - if !doExist { - t.Errorf("expected '%s' to exist as log entry", entry) - } - } - }) - } -} diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index b31c5e707..0d3e88f74 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -5,11 +5,15 @@ import ( "errors" "fmt" "os" + "os/exec" + "strings" + "syscall" "time" "github.com/alexliesenfeld/health" "github.com/chromedp/cdproto/network" "github.com/dlclark/regexp2" + "github.com/pdfcpu/pdfcpu/pkg/pdfcpu" flag "github.com/spf13/pflag" "go.uber.org/zap" @@ -23,7 +27,7 @@ func init() { var ( // ErrInvalidEmulatedMediaType happens if the emulated media type is not - // "screen" nor "print". Empty value are allowed though. + // "screen" nor "print". Empty value is allowed, though. ErrInvalidEmulatedMediaType = errors.New("invalid emulated media type") // ErrInvalidEvaluationExpression happens if an evaluation expression @@ -35,11 +39,11 @@ var ( ErrRpccMessageTooLarge = errors.New("rpcc message too large") // ErrInvalidHttpStatusCode happens when the status code from the main page - // matches with one of the entry in [Options.FailOnHttpStatusCodes]. + // matches with one of the entries in [Options.FailOnHttpStatusCodes]. ErrInvalidHttpStatusCode = errors.New("invalid HTTP status code") // ErrInvalidResourceHttpStatusCode happens when the status code from one - // or more resources matches with one of the entry in + // or more resources matches with one of the entries in // [Options.FailOnResourceHttpStatusCodes]. ErrInvalidResourceHttpStatusCode = errors.New("invalid resource HTTP status code") @@ -60,17 +64,24 @@ var ( // PdfOptions.OmitBackground is set to true but not PdfOptions.PrintBackground. ErrOmitBackgroundWithoutPrintBackground = errors.New("omit background without print background") + // ErrPrintingFailed happens if the printing failed for an unknown reason. + ErrPrintingFailed = errors.New("printing failed") + // ErrInvalidPrinterSettings happens if the PdfOptions have one or more // aberrant values. ErrInvalidPrinterSettings = errors.New("invalid printer settings") - // ErrPageRangesSyntaxError happens if the PdfOptions have an invalid page - // ranges. + // ErrPageRangesSyntaxError happens if the PdfOptions page + // range syntax is invalid. ErrPageRangesSyntaxError = errors.New("page ranges syntax error") + + // ErrPageRangesExceedsPageCount happens if the PdfOptions have an invalid + // page range. + ErrPageRangesExceedsPageCount = errors.New("page ranges exceeds page count") ) -// Chromium is a module which provides both an [Api] and routes for converting -// HTML document to PDF. +// Chromium is a module that provides both an [Api] and routes for converting +// an HTML document to PDF. type Chromium struct { autoStart bool disableRoutes bool @@ -125,15 +136,15 @@ type Options struct { UserAgent string // ExtraHttpHeaders are extra HTTP headers to send by Chromium while - // loading he HTML document. + // loading the HTML document. ExtraHttpHeaders []ExtraHttpHeader // EmulatedMediaType is the media type to emulate, either "screen" or // "print". EmulatedMediaType string - // OmitBackground hides default white background and allows generating PDFs - // with transparency. + // OmitBackground hides the default white background and allows generating + // PDFs with transparency. OmitBackground bool } @@ -194,9 +205,9 @@ type PdfOptions struct { // Page ranges to print, e.g., '1-5, 8, 11-13'. Empty means all pages. PageRanges string - // HeaderTemplate is the HTML template of the header. It should be valid - // HTML markup with following classes used to inject printing values into - // them: + // HeaderTemplate is the HTML template of the header. It should be a valid + // HTML markup with the following classes used to inject printing values + // into them: // - date: formatted print date // - title: document title // - url: document location @@ -217,6 +228,14 @@ type PdfOptions struct { // GenerateDocumentOutline defines whether the document outline should be // embedded into the PDF. GenerateDocumentOutline bool + + // Bookmarks to be inserted unmarshaled + // as defined in pdfcpu bookmarks export + Bookmarks pdfcpu.BookmarkTree + + // GenerateTaggedPdf defines whether to generate tagged (accessible) + // PDF. + GenerateTaggedPdf bool } // DefaultPdfOptions returns the default values for PdfOptions. @@ -238,6 +257,8 @@ func DefaultPdfOptions() PdfOptions { FooterTemplate: "", PreferCssPageSize: false, GenerateDocumentOutline: false, + Bookmarks: pdfcpu.BookmarkTree{}, + GenerateTaggedPdf: false, } } @@ -335,7 +356,7 @@ type Api interface { Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error } -// Provider is a module interface which exposes a method for creating an [Api] +// Provider is a module interface that exposes a method for creating an [Api] // for other modules. // // func (m *YourModule) Provision(ctx *gotenberg.Context) error { @@ -352,11 +373,10 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor { ID: "chromium", FlagSet: func() *flag.FlagSet { fs := flag.NewFlagSet("chromium", flag.ExitOnError) - fs.Int64("chromium-restart-after", 0, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature") + fs.Int64("chromium-restart-after", 10, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature") fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature") fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion") fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart") - fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode") fs.Bool("chromium-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost") fs.Bool("chromium-ignore-certificate-errors", false, "Ignore the certificate errors") fs.Bool("chromium-disable-web-security", false, "Don't enforce the same-origin policy") @@ -370,6 +390,13 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor { fs.Bool("chromium-disable-javascript", false, "Disable JavaScript") fs.Bool("chromium-disable-routes", false, "Disable the routes") + // Deprecated flags. + fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode") + err := fs.MarkDeprecated("chromium-incognito", "this flag is ignored as it provides no benefits") + if err != nil { + panic(err) + } + return fs }(), New: func() gotenberg.Module { return new(Chromium) }, @@ -387,9 +414,13 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error { return errors.New("CHROMIUM_BIN_PATH environment variable is not set") } + hyphenDataDirPath, ok := os.LookupEnv("CHROMIUM_HYPHEN_DATA_DIR_PATH") + if !ok { + return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set") + } + mod.args = browserArguments{ binPath: binPath, - incognito: flags.MustBool("chromium-incognito"), allowInsecureLocalhost: flags.MustBool("chromium-allow-insecure-localhost"), ignoreCertificateErrors: flags.MustBool("chromium-ignore-certificate-errors"), disableWebSecurity: flags.MustBool("chromium-disable-web-security"), @@ -397,6 +428,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error { hostResolverRules: flags.MustString("chromium-host-resolver-rules"), proxyServer: flags.MustString("chromium-proxy-server"), wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"), + hyphenDataDirPath: hyphenDataDirPath, allowList: flags.MustRegexp("chromium-allow-list"), denyList: flags.MustRegexp("chromium-deny-list"), @@ -441,6 +473,11 @@ func (mod *Chromium) Validate() error { return fmt.Errorf("chromium binary path does not exist: %w", err) } + _, err = os.Stat(mod.args.hyphenDataDirPath) + if os.IsNotExist(err) { + return fmt.Errorf("chromium hyphen-data directory path does not exist: %w", err) + } + return nil } @@ -470,8 +507,8 @@ func (mod *Chromium) StartupMessage() string { // Stop stops the current browser instance. func (mod *Chromium) Stop(ctx context.Context) error { - // Block until the context is done so that other module may gracefully stop - // before we do a shutdown. + // Block until the context is done so that another module may gracefully + // stop before we do a shutdown. mod.logger.Debug("wait for the end of grace duration") <-ctx.Done() @@ -484,6 +521,23 @@ func (mod *Chromium) Stop(ctx context.Context) error { return fmt.Errorf("stop Chromium: %w", err) } +// Debug returns additional debug data. +func (mod *Chromium) Debug() map[string]interface{} { + debug := make(map[string]interface{}) + + cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + debug["version"] = err.Error() + return debug + } + + debug["version"] = strings.TrimSpace(string(output)) + return debug +} + // Metrics returns the metrics. func (mod *Chromium) Metrics() ([]gotenberg.Metric, error) { return []gotenberg.Metric{ @@ -593,6 +647,7 @@ var ( _ gotenberg.Provisioner = (*Chromium)(nil) _ gotenberg.Validator = (*Chromium)(nil) _ gotenberg.App = (*Chromium)(nil) + _ gotenberg.Debuggable = (*Chromium)(nil) _ gotenberg.MetricsProvider = (*Chromium)(nil) _ api.HealthChecker = (*Chromium)(nil) _ api.Router = (*Chromium)(nil) diff --git a/pkg/modules/chromium/chromium_test.go b/pkg/modules/chromium/chromium_test.go deleted file mode 100644 index 38f28c1ef..000000000 --- a/pkg/modules/chromium/chromium_test.go +++ /dev/null @@ -1,589 +0,0 @@ -package chromium - -import ( - "context" - "errors" - "os" - "reflect" - "testing" - "time" - - "github.com/alexliesenfeld/health" - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestDefaultOptions(t *testing.T) { - actual := DefaultOptions() - notExpect := Options{} - - if reflect.DeepEqual(actual, notExpect) { - t.Errorf("expected %v and got identical %v", actual, notExpect) - } -} - -func TestDefaultPdfOptions(t *testing.T) { - actual := DefaultPdfOptions() - notExpect := PdfOptions{} - - if reflect.DeepEqual(actual, notExpect) { - t.Errorf("expected %v and got identical %v", actual, notExpect) - } -} - -func TestDefaultScreenshotOptions(t *testing.T) { - actual := DefaultScreenshotOptions() - notExpect := ScreenshotOptions{} - - if reflect.DeepEqual(actual, notExpect) { - t.Errorf("expected %v and got identical %v", actual, notExpect) - } -} - -func TestChromium_Descriptor(t *testing.T) { - descriptor := new(Chromium).Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(Chromium)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestChromium_Provision(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *gotenberg.Context - expectError bool - }{ - { - scenario: "no logger provider", - ctx: func() *gotenberg.Context { - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Chromium).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{}, - ) - }(), - expectError: true, - }, - { - scenario: "no logger from logger provider", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - gotenberg.LoggerProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} - } - mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { - return nil, errors.New("foo") - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Chromium).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "no PDF engine provider", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - gotenberg.LoggerProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} - } - mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { - return zap.NewNop(), nil - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Chromium).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "no PDF engine from PDF engine provider", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - gotenberg.LoggerProviderMock - gotenberg.PdfEngineProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} - } - mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { - return zap.NewNop(), nil - } - mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) { - return nil, errors.New("foo") - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Chromium).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "provision success", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - gotenberg.LoggerProviderMock - gotenberg.PdfEngineProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} - } - mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { - return zap.NewNop(), nil - } - mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) { - return new(gotenberg.PdfEngineMock), nil - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Chromium).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(Chromium) - err := mod.Provision(tc.ctx) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestChromium_Validate(t *testing.T) { - for _, tc := range []struct { - scenario string - binPath string - expectError bool - }{ - { - scenario: "empty bin path", - binPath: "", - expectError: true, - }, - { - scenario: "bin path does not exist", - binPath: "/foo", - expectError: true, - }, - { - scenario: "validate success", - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(Chromium) - mod.args = browserArguments{ - binPath: tc.binPath, - } - err := mod.Validate() - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestChromium_Start(t *testing.T) { - for _, tc := range []struct { - scenario string - autoStart bool - supervisor *gotenberg.ProcessSupervisorMock - expectError bool - }{ - { - scenario: "no auto-start", - autoStart: false, - expectError: false, - }, - { - scenario: "auto-start success", - autoStart: true, - supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error { - return nil - }}, - expectError: false, - }, - { - scenario: "auto-start failed", - autoStart: true, - supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error { - return errors.New("foo") - }}, - expectError: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(Chromium) - mod.autoStart = tc.autoStart - mod.supervisor = tc.supervisor - - err := mod.Start() - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestChromium_StartupMessage(t *testing.T) { - mod := new(Chromium) - - mod.autoStart = true - autoStartMsg := mod.StartupMessage() - - mod.autoStart = false - noAutoStartMsg := mod.StartupMessage() - - if autoStartMsg == noAutoStartMsg { - t.Errorf("expected differrent startup messages based on auto start, but got '%s'", autoStartMsg) - } -} - -func TestChromium_Stop(t *testing.T) { - for _, tc := range []struct { - scenario string - supervisor *gotenberg.ProcessSupervisorMock - expectError bool - }{ - { - scenario: "stop success", - supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error { - return nil - }}, - expectError: false, - }, - { - scenario: "stop failed", - supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error { - return errors.New("foo") - }}, - expectError: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(Chromium) - mod.logger = zap.NewNop() - mod.supervisor = tc.supervisor - - ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second) - cancel() - - err := mod.Stop(ctx) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestChromium_Metrics(t *testing.T) { - mod := new(Chromium) - mod.supervisor = &gotenberg.ProcessSupervisorMock{ - ReqQueueSizeMock: func() int64 { - return 10 - }, - RestartsCountMock: func() int64 { - return 0 - }, - } - - metrics, err := mod.Metrics() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if len(metrics) != 2 { - t.Fatalf("expected %d metrics, but got %d", 2, len(metrics)) - } - - actual := metrics[0].Read() - if actual != float64(10) { - t.Errorf("expected %f for chromium_requests_queue_size, but got %f", float64(10), actual) - } - - actual = metrics[1].Read() - if actual != float64(0) { - t.Errorf("expected %f for chromium_restarts_count, but got %f", float64(0), actual) - } -} - -func TestChromium_Checks(t *testing.T) { - for _, tc := range []struct { - scenario string - supervisor gotenberg.ProcessSupervisor - expectAvailabilityStatus health.AvailabilityStatus - }{ - { - scenario: "healthy module", - supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool { - return true - }}, - expectAvailabilityStatus: health.StatusUp, - }, - { - scenario: "unhealthy module", - supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool { - return false - }}, - expectAvailabilityStatus: health.StatusDown, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(Chromium) - mod.supervisor = tc.supervisor - - checks, err := mod.Checks() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - checker := health.NewChecker(checks...) - result := checker.Check(context.Background()) - - if result.Status != tc.expectAvailabilityStatus { - t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status) - } - }) - } -} - -func TestChromium_Ready(t *testing.T) { - for _, tc := range []struct { - scenario string - autoStart bool - startTimeout time.Duration - browser browser - expectError bool - }{ - { - scenario: "no auto-start", - autoStart: false, - startTimeout: time.Duration(30) * time.Second, - browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool { - return false - }}}, - expectError: false, - }, - { - scenario: "auto-start: context done", - autoStart: true, - startTimeout: time.Duration(200) * time.Millisecond, - browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool { - return false - }}}, - expectError: true, - }, - { - scenario: "auto-start success", - autoStart: true, - startTimeout: time.Duration(30) * time.Second, - browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool { - return true - }}}, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(Chromium) - mod.autoStart = tc.autoStart - mod.args = browserArguments{wsUrlReadTimeout: tc.startTimeout} - mod.browser = tc.browser - - err := mod.Ready() - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestChromium_Chromium(t *testing.T) { - mod := new(Chromium) - - _, err := mod.Chromium() - if err != nil { - t.Errorf("expected no error but got: %v", err) - } -} - -func TestChromium_Routes(t *testing.T) { - for _, tc := range []struct { - scenario string - expectRoutes int - disableRoutes bool - }{ - { - scenario: "routes not disabled", - expectRoutes: 6, - disableRoutes: false, - }, - { - scenario: "routes disabled", - expectRoutes: 0, - disableRoutes: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(Chromium) - mod.disableRoutes = tc.disableRoutes - - routes, err := mod.Routes() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectRoutes != len(routes) { - t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes)) - } - }) - } -} - -func TestChromium_Pdf(t *testing.T) { - for _, tc := range []struct { - scenario string - supervisor gotenberg.ProcessSupervisor - browser browser - expectError bool - }{ - { - scenario: "PDF task success", - browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return nil - }}, - expectError: false, - }, - { - scenario: "PDF task error", - browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return errors.New("PDF task error") - }}, - expectError: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(Chromium) - mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error { - return task() - }} - mod.browser = tc.browser - - err := mod.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{}) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestChromium_Screenshot(t *testing.T) { - for _, tc := range []struct { - scenario string - supervisor gotenberg.ProcessSupervisor - browser browser - expectError bool - }{ - { - scenario: "Screenshot task success", - browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return nil - }}, - expectError: false, - }, - { - scenario: "Screenshot task error", - browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return errors.New("screenshot task error") - }}, - expectError: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(Chromium) - mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error { - return task() - }} - mod.browser = tc.browser - - err := mod.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{}) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} diff --git a/pkg/modules/chromium/debug_test.go b/pkg/modules/chromium/debug_test.go deleted file mode 100644 index 579f84db3..000000000 --- a/pkg/modules/chromium/debug_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package chromium - -import ( - "testing" - - "go.uber.org/zap" -) - -func TestDebugLogger_Write(t *testing.T) { - actual, err := (&debugLogger{logger: zap.NewNop()}).Write([]byte("foo")) - expected := len([]byte("foo")) - - if actual != expected { - t.Errorf("expected %d but got %d", expected, actual) - } - - if err != nil { - t.Errorf("expected not error but got: %v", err) - } -} - -func TestDebugLogger_Printf(t *testing.T) { - (&debugLogger{logger: zap.NewNop()}).Printf("%s", "foo") -} diff --git a/pkg/modules/chromium/events.go b/pkg/modules/chromium/events.go index ec02ef08d..f249b2bd2 100644 --- a/pkg/modules/chromium/events.go +++ b/pkg/modules/chromium/events.go @@ -72,10 +72,10 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option var extraHttpHeadersToSet []ExtraHttpHeader if len(options.extraHttpHeaders) > 0 { - // The user want to set extra HTTP headers. + // The user wants to set extra HTTP headers. // First, we have to check if at least one header has to be - // set for current request. + // set for the current request. for _, header := range options.extraHttpHeaders { if header.Scope == nil { // Non-scoped header. @@ -147,8 +147,8 @@ type eventResponseReceivedOptions struct { invalidResourceHttpStatusCodeMu *sync.RWMutex } -// listenForEventResponseReceived listens for an invalid HTTP status code that -// is returned by the main page or by one or more resources. +// listenForEventResponseReceived listens for an invalid HTTP status code +// returned by the main page or by one or more resources. // See: // https://github.com/gotenberg/gotenberg/issues/613. // https://github.com/gotenberg/gotenberg/issues/1021. @@ -235,6 +235,7 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option "net::ERR_BLOCKED_BY_CLIENT", "net::ERR_BLOCKED_BY_RESPONSE", "net::ERR_FILE_NOT_FOUND", + "net::ERR_HTTP2_PROTOCOL_ERROR", } if !slices.Contains(errors, ev.ErrorText) { logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors)) diff --git a/pkg/modules/chromium/mocks_test.go b/pkg/modules/chromium/mocks_test.go deleted file mode 100644 index 1f361a8aa..000000000 --- a/pkg/modules/chromium/mocks_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package chromium - -import ( - "context" - "testing" - - "go.uber.org/zap" -) - -func TestApiMock(t *testing.T) { - mock := &ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return nil - }, - ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return nil - }, - } - - err := mock.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{}) - if err != nil { - t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err) - } - - err = mock.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{}) - if err != nil { - t.Errorf("expected no error from ApiMock.Screenshot, but got: %v", err) - } -} - -func TestBrowserMock(t *testing.T) { - mock := &browserMock{ - pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return nil - }, - screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return nil - }, - } - - err := mock.pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{}) - if err != nil { - t.Errorf("expected no error from browserMock.pdf, but got: %v", err) - } - - err = mock.screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{}) - if err != nil { - t.Errorf("expected no error from browserMock.screenshot, but got: %v", err) - } -} diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index 2ae33b250..3575b5bd8 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -14,9 +14,10 @@ import ( "time" "github.com/dlclark/regexp2" + "github.com/gomarkdown/markdown" "github.com/labstack/echo/v4" "github.com/microcosm-cc/bluemonday" - "github.com/russross/blackfriday/v2" + "github.com/pdfcpu/pdfcpu/pkg/pdfcpu" "go.uber.org/multierr" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" @@ -24,8 +25,13 @@ import ( "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines" ) +var sameSiteRegexp = regexp2.MustCompile( + `("sameSite"\s*:\s*")(?i:(lax|strict|none))(")`, + regexp2.None, +) + // FormDataChromiumOptions creates [Options] from the form data. Fallback to -// default value if the considered key is not present. +// the default value if the considered key is not present. func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { defaultOptions := DefaultOptions() @@ -84,7 +90,30 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { return nil } - err := json.Unmarshal([]byte(value), &cookies) + // sameSite attribute from cookies must accept case-insensitive + // values. + // See https://github.com/gotenberg/gotenberg/issues/1331. + normalized, err := sameSiteRegexp.ReplaceFunc(value, func(m regexp2.Match) string { + groups := m.Groups() + provided := groups[2].String() + var canon string + switch strings.ToLower(provided) { + case "lax": + canon = "Lax" + case "strict": + canon = "Strict" + case "none": + canon = "None" + default: + canon = provided + } + return groups[1].String() + canon + groups[3].String() + }, -1, -1) + if err != nil { + return fmt.Errorf("normalize sameSite from cookies: %w", err) + } + + err = json.Unmarshal([]byte(normalized), &cookies) if err != nil { return fmt.Errorf("unmarshal cookies: %w", err) } @@ -141,7 +170,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { var scopeRegexp *regexp2.Regexp if len(scope) > 0 { - p, errCompile := regexp2.Compile(scope, 0) + p, errCompile := regexp2.Compile(scope, regexp2.None) if errCompile != nil { err = multierr.Append(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile)) continue @@ -194,7 +223,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { } // FormDataChromiumPdfOptions creates [PdfOptions] from the form data. Fallback to -// default value if the considered key is not present. +// the default value if the considered key is not present. func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) { form, options := FormDataChromiumOptions(ctx) defaultPdfOptions := DefaultPdfOptions() @@ -207,6 +236,8 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) { headerTemplate, footerTemplate string preferCssPageSize bool generateDocumentOutline bool + generateTaggedPdf bool + bookmarks pdfcpu.BookmarkTree ) form. @@ -224,7 +255,19 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) { Content("header.html", &headerTemplate, defaultPdfOptions.HeaderTemplate). Content("footer.html", &footerTemplate, defaultPdfOptions.FooterTemplate). Bool("preferCssPageSize", &preferCssPageSize, defaultPdfOptions.PreferCssPageSize). - Bool("generateDocumentOutline", &generateDocumentOutline, defaultPdfOptions.GenerateDocumentOutline) + Bool("generateDocumentOutline", &generateDocumentOutline, defaultPdfOptions.GenerateDocumentOutline). + Bool("generateTaggedPdf", &generateTaggedPdf, defaultPdfOptions.GenerateTaggedPdf). + Custom("bookmarks", func(value string) error { + if len(value) > 0 { + err := json.Unmarshal([]byte(value), &bookmarks) + if err != nil { + return fmt.Errorf("unmarshal bookmarks: %w", err) + } + } else { + bookmarks = defaultPdfOptions.Bookmarks + } + return nil + }) pdfOptions := PdfOptions{ Options: options, @@ -243,13 +286,15 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) { FooterTemplate: footerTemplate, PreferCssPageSize: preferCssPageSize, GenerateDocumentOutline: generateDocumentOutline, + Bookmarks: bookmarks, + GenerateTaggedPdf: generateTaggedPdf, } return form, pdfOptions } // FormDataChromiumScreenshotOptions creates [ScreenshotOptions] from the form -// data. Fallback to default value if the considered key is not present. +// data. Fallback to the default value if the considered key is not present. func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, ScreenshotOptions) { form, options := FormDataChromiumOptions(ctx) defaultScreenshotOptions := DefaultScreenshotOptions() @@ -326,8 +371,11 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { Handler: func(c echo.Context) error { ctx := c.Get("context").(*api.Context) form, options := FormDataChromiumPdfOptions(ctx) + mode := pdfengines.FormDataPdfSplitMode(form, false) pdfFormats := pdfengines.FormDataPdfFormats(form) - metadata := pdfengines.FormDataPdfMetadata(form) + metadata := pdfengines.FormDataPdfMetadata(form, false) + userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) + embedPaths := pdfengines.FormDataPdfEmbeds(form) var url string err := form. @@ -337,7 +385,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { 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, userPassword, ownerPassword, embedPaths) if err != nil { return fmt.Errorf("convert URL to PDF: %w", err) } @@ -386,8 +434,11 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { Handler: func(c echo.Context) error { ctx := c.Get("context").(*api.Context) form, options := FormDataChromiumPdfOptions(ctx) + mode := pdfengines.FormDataPdfSplitMode(form, false) pdfFormats := pdfengines.FormDataPdfFormats(form) - metadata := pdfengines.FormDataPdfMetadata(form) + metadata := pdfengines.FormDataPdfMetadata(form, false) + userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) + embedPaths := pdfengines.FormDataPdfEmbeds(form) var inputPath string err := form. @@ -398,7 +449,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } 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, userPassword, ownerPassword, embedPaths) if err != nil { return fmt.Errorf("convert HTML to PDF: %w", err) } @@ -448,8 +499,11 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { Handler: func(c echo.Context) error { ctx := c.Get("context").(*api.Context) form, options := FormDataChromiumPdfOptions(ctx) + mode := pdfengines.FormDataPdfSplitMode(form, false) pdfFormats := pdfengines.FormDataPdfFormats(form) - metadata := pdfengines.FormDataPdfMetadata(form) + metadata := pdfengines.FormDataPdfMetadata(form, false) + userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) + embedPaths := pdfengines.FormDataPdfEmbeds(form) var ( inputPath string @@ -469,7 +523,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("transform markdown file(s) to HTML: %w", err) } - err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths) if err != nil { return fmt.Errorf("convert markdown to PDF: %w", err) } @@ -480,7 +534,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } // screenshotMarkdownRoute returns an [api.Route] which can take a screenshot -// from markdown files. +// from Markdown files. func screenshotMarkdownRoute(chromium Api) api.Route { return api.Route{ Method: http.MethodPost, @@ -519,7 +573,7 @@ func screenshotMarkdownRoute(chromium Api) api.Route { } func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) (string, error) { - // We have to convert each markdown file referenced in the HTML + // We have to convert each Markdown file referenced in the HTML // file to... HTML. Thanks to the "html/template" package, we are // able to provide the "toHTML" function which the user may call // directly inside the HTML file. @@ -555,7 +609,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) return "", fmt.Errorf("read markdown file '%s': %w", filename, err) } - unsafe := blackfriday.Run(b) + unsafe := markdown.ToHTML(b, nil, nil) sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe) // #nosec @@ -593,8 +647,11 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) return fmt.Sprintf("file://%s", inputPath), nil } -func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, 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{}, userPassword, ownerPassword string, embedPaths []string) error { outputPath := ctx.GeneratePath(".pdf") + // See https://github.com/gotenberg/gotenberg/issues/1130. + filename := ctx.OutputFilename(outputPath) + outputPath = ctx.GeneratePathFromFilename(filename) err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options) err = handleChromiumError(err, options.Options) @@ -609,6 +666,16 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url ) } + if errors.Is(err, ErrPrintingFailed) { + return api.WrapError( + fmt.Errorf("convert to PDF: %w", err), + api.NewSentinelHttpError( + http.StatusBadRequest, + "Chromium failed to print the PDF; this usually happens when the page is too large", + ), + ) + } + if errors.Is(err, ErrInvalidPrinterSettings) { return api.WrapError( fmt.Errorf("convert to PDF: %w", err), @@ -619,12 +686,22 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url ) } + if errors.Is(err, ErrPageRangesExceedsPageCount) { + return api.WrapError( + fmt.Errorf("convert to PDF: %w", err), + api.NewSentinelHttpError( + http.StatusBadRequest, + fmt.Sprintf("The page ranges '%s' (nativePageRanges) exceeds the page count", options.PageRanges), + ), + ) + } + if errors.Is(err, ErrPageRangesSyntaxError) { return api.WrapError( fmt.Errorf("convert to PDF: %w", err), api.NewSentinelHttpError( http.StatusBadRequest, - fmt.Sprintf("Chromium does not handle the page ranges '%s' (nativePageRanges)", options.PageRanges), + fmt.Sprintf("Chromium does not handle the page ranges '%s' (nativePageRanges) syntax", options.PageRanges), ), ) } @@ -632,16 +709,62 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url return fmt.Errorf("convert to PDF: %w", err) } - outputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, []string{outputPath}) + if options.GenerateDocumentOutline { + if len(options.Bookmarks.Bookmarks) > 0 { + bookmarks, errMarshal := json.Marshal(options.Bookmarks) + outputBMPath := ctx.GeneratePath(".pdf") + + if errMarshal == nil { + outputPath, err = pdfengines.ImportBookmarksStub(ctx, engine, outputPath, bookmarks, outputBMPath) + if err != nil { + return fmt.Errorf("import bookmarks into PDF err: %w", err) + } + } else { + return fmt.Errorf("import bookmarks into PDF errMarshal : %w", errMarshal) + } + } + } + + outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath}) + if err != nil { + return fmt.Errorf("split PDF: %w", err) + } + + convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths) if err != nil { - return fmt.Errorf("convert PDF: %w", err) + return fmt.Errorf("convert PDF(s): %w", err) } - err = pdfengines.WriteMetadataStub(ctx, engine, metadata, outputPaths) + err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths) + if err != nil { + return fmt.Errorf("embed files into PDFs: %w", err) + } + + err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths) if err != nil { return fmt.Errorf("write metadata: %w", err) } + err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) + if err != nil { + return fmt.Errorf("encrypt PDFs: %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) diff --git a/pkg/modules/chromium/routes_test.go b/pkg/modules/chromium/routes_test.go deleted file mode 100644 index 1e7363ba6..000000000 --- a/pkg/modules/chromium/routes_test.go +++ /dev/null @@ -1,1869 +0,0 @@ -package chromium - -import ( - "context" - "errors" - "fmt" - "net/http" - "os" - "reflect" - "sort" - "testing" - - "github.com/dlclark/regexp2" - "github.com/google/uuid" - "github.com/labstack/echo/v4" - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" - "github.com/gotenberg/gotenberg/v8/pkg/modules/api" -) - -func TestFormDataChromiumOptions(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - expectedOptions Options - compareWithoutDeepEqual bool - expectValidationError bool - }{ - { - scenario: "no custom form fields", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectedOptions: DefaultOptions(), - compareWithoutDeepEqual: false, - expectValidationError: false, - }, - { - scenario: "invalid failOnHttpStatusCodes form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "failOnHttpStatusCodes": { - "foo", - }, - }) - return ctx - }(), - expectedOptions: func() Options { - options := DefaultOptions() - options.FailOnHttpStatusCodes = nil - return options - }(), - compareWithoutDeepEqual: false, - expectValidationError: true, - }, - { - scenario: "valid failOnHttpStatusCodes form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "failOnHttpStatusCodes": { - `[399,499,599]`, - }, - }) - return ctx - }(), - expectedOptions: func() Options { - options := DefaultOptions() - options.FailOnHttpStatusCodes = []int64{399, 499, 599} - return options - }(), - compareWithoutDeepEqual: false, - expectValidationError: false, - }, - { - scenario: "invalid failOnResourceHttpStatusCodes form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "failOnResourceHttpStatusCodes": { - "foo", - }, - }) - return ctx - }(), - expectedOptions: func() Options { - options := DefaultOptions() - options.FailOnResourceHttpStatusCodes = nil - return options - }(), - compareWithoutDeepEqual: false, - expectValidationError: true, - }, - { - scenario: "valid failOnResourceHttpStatusCodes form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "failOnResourceHttpStatusCodes": { - `[399,499,599]`, - }, - }) - return ctx - }(), - expectedOptions: func() Options { - options := DefaultOptions() - options.FailOnResourceHttpStatusCodes = []int64{399, 499, 599} - return options - }(), - compareWithoutDeepEqual: false, - expectValidationError: false, - }, - { - scenario: "invalid cookies form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "cookies": { - "foo", - }, - }) - return ctx - }(), - expectedOptions: DefaultOptions(), - compareWithoutDeepEqual: false, - expectValidationError: true, - }, - { - scenario: "invalid cookies form field (missing required values)", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "cookies": { - "[{}]", - }, - }) - return ctx - }(), - expectedOptions: func() Options { - options := DefaultOptions() - // No validation in this method, so it still instantiates - // an empty item. - options.Cookies = []Cookie{{}} - return options - }(), - compareWithoutDeepEqual: false, - expectValidationError: true, - }, - { - scenario: "valid cookies form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "cookies": { - `[{"name":"foo","value":"bar","domain":".foo.bar"}]`, - }, - }) - return ctx - }(), - expectedOptions: func() Options { - options := DefaultOptions() - options.Cookies = []Cookie{{ - Name: "foo", - Value: "bar", - Domain: ".foo.bar", - }} - return options - }(), - compareWithoutDeepEqual: false, - expectValidationError: false, - }, - { - scenario: "invalid extraHttpHeaders form field: cannot unmarshall", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "extraHttpHeaders": { - "foo", - }, - }) - return ctx - }(), - expectedOptions: DefaultOptions(), - compareWithoutDeepEqual: false, - expectValidationError: true, - }, - { - scenario: "invalid extraHttpHeaders form field: invalid scope", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "extraHttpHeaders": { - `{"foo":"bar;scope;;"}`, - }, - }) - return ctx - }(), - expectedOptions: DefaultOptions(), - compareWithoutDeepEqual: false, - expectValidationError: true, - }, - { - scenario: "invalid extraHttpHeaders form field: invalid scope regex pattern", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "extraHttpHeaders": { - `{"foo":"bar;scope=*."}`, - }, - }) - return ctx - }(), - expectedOptions: DefaultOptions(), - compareWithoutDeepEqual: false, - expectValidationError: true, - }, - { - scenario: "valid extraHttpHeaders form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "extraHttpHeaders": { - `{"foo":"bar","baz":"qux;scope=https?:\\/\\/([a-zA-Z0-9-]+\\.)*qux\\.com\\/.*"}`, - }, - }) - return ctx - }(), - expectedOptions: func() Options { - options := DefaultOptions() - options.ExtraHttpHeaders = []ExtraHttpHeader{ - { - Name: "foo", - Value: "bar", - }, - { - Name: "baz", - Value: "qux", - Scope: regexp2.MustCompile(`https?:\/\/([a-zA-Z0-9-]+\.)*qux\.com\/.*`, 0), - }, - } - return options - }(), - compareWithoutDeepEqual: true, - expectValidationError: false, - }, - { - scenario: "invalid emulatedMediaType form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "emulatedMediaType": { - "foo", - }, - }) - return ctx - }(), - expectedOptions: DefaultOptions(), - expectValidationError: true, - }, - { - scenario: "valid emulatedMediaType form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "emulatedMediaType": { - "screen", - }, - }) - return ctx - }(), - expectedOptions: func() Options { - options := DefaultOptions() - options.EmulatedMediaType = "screen" - return options - }(), - expectValidationError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - form, actual := FormDataChromiumOptions(tc.ctx.Context) - - if tc.compareWithoutDeepEqual { - if len(tc.expectedOptions.ExtraHttpHeaders) != len(actual.ExtraHttpHeaders) { - t.Fatalf("expected %d extra HTTP headers, but got %d", len(tc.expectedOptions.ExtraHttpHeaders), len(actual.ExtraHttpHeaders)) - } - - sort.Slice(tc.expectedOptions.ExtraHttpHeaders, func(i, j int) bool { - return tc.expectedOptions.ExtraHttpHeaders[i].Name < tc.expectedOptions.ExtraHttpHeaders[j].Name - }) - sort.Slice(actual.ExtraHttpHeaders, func(i, j int) bool { - return actual.ExtraHttpHeaders[i].Name < actual.ExtraHttpHeaders[j].Name - }) - - for i := range tc.expectedOptions.ExtraHttpHeaders { - if tc.expectedOptions.ExtraHttpHeaders[i].Name != actual.ExtraHttpHeaders[i].Name { - t.Fatalf("expected '%s' extra HTTP header, but got '%s'", tc.expectedOptions.ExtraHttpHeaders[i].Name, tc.expectedOptions.ExtraHttpHeaders[i].Name) - } - - if tc.expectedOptions.ExtraHttpHeaders[i].Value != actual.ExtraHttpHeaders[i].Value { - t.Fatalf("expected '%s' as value for extra HTTP header '%s', but got '%s'", tc.expectedOptions.ExtraHttpHeaders[i].Value, tc.expectedOptions.ExtraHttpHeaders[i].Name, actual.ExtraHttpHeaders[i].Value) - } - - var expectedScope string - if tc.expectedOptions.ExtraHttpHeaders[i].Scope != nil { - expectedScope = tc.expectedOptions.ExtraHttpHeaders[i].Scope.String() - } - var actualScope string - if actual.ExtraHttpHeaders[i].Scope != nil { - actualScope = actual.ExtraHttpHeaders[i].Scope.String() - } - - if expectedScope != actualScope { - t.Fatalf("expected '%s' as scope for extra HTTP header '%s', but got '%s'", expectedScope, tc.expectedOptions.ExtraHttpHeaders[i].Name, actualScope) - } - } - } else { - if !reflect.DeepEqual(actual, tc.expectedOptions) { - t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, 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 TestFormDataChromiumPdfOptions(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - expectedOptions PdfOptions - expectValidationError bool - }{ - { - scenario: "no custom form fields", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectedOptions: DefaultPdfOptions(), - expectValidationError: false, - }, - { - scenario: "custom form fields (Options & PdfOptions)", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "landscape": { - "true", - }, - "emulatedMediaType": { - "screen", - }, - }) - return ctx - }(), - expectedOptions: func() PdfOptions { - options := DefaultPdfOptions() - options.Landscape = true - options.EmulatedMediaType = "screen" - return options - }(), - expectValidationError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - form, actual := FormDataChromiumPdfOptions(tc.ctx.Context) - - if !reflect.DeepEqual(actual, tc.expectedOptions) { - t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, 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 TestFormDataChromiumScreenshotOptions(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - expectedOptions ScreenshotOptions - expectValidationError bool - }{ - { - scenario: "no custom form fields", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectedOptions: DefaultScreenshotOptions(), - expectValidationError: false, - }, - { - scenario: "invalid format form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "format": { - "gif", - }, - }) - return ctx - }(), - expectedOptions: func() ScreenshotOptions { - options := DefaultScreenshotOptions() - options.Format = "" - return options - }(), - expectValidationError: true, - }, - { - scenario: "valid png format form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "format": { - "png", - }, - }) - return ctx - }(), - expectedOptions: func() ScreenshotOptions { - options := DefaultScreenshotOptions() - options.Format = "png" - return options - }(), - expectValidationError: false, - }, - { - scenario: "valid jpeg format form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "format": { - "jpeg", - }, - }) - return ctx - }(), - expectedOptions: func() ScreenshotOptions { - options := DefaultScreenshotOptions() - options.Format = "jpeg" - return options - }(), - expectValidationError: false, - }, - { - scenario: "valid webp format form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "format": { - "webp", - }, - }) - return ctx - }(), - expectedOptions: func() ScreenshotOptions { - options := DefaultScreenshotOptions() - options.Format = "webp" - return options - }(), - expectValidationError: false, - }, - { - scenario: "invalid quality form field (not an integer)", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "quality": { - "foo", - }, - }) - return ctx - }(), - expectedOptions: func() ScreenshotOptions { - options := DefaultScreenshotOptions() - options.Quality = 0 - return options - }(), - expectValidationError: true, - }, - { - scenario: "invalid quality form field (< 0)", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "quality": { - "-1", - }, - }) - return ctx - }(), - expectedOptions: func() ScreenshotOptions { - options := DefaultScreenshotOptions() - options.Quality = 0 - return options - }(), - expectValidationError: true, - }, - { - scenario: "invalid quality form field (> 100)", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "quality": { - "101", - }, - }) - return ctx - }(), - expectedOptions: func() ScreenshotOptions { - options := DefaultScreenshotOptions() - options.Quality = 0 - return options - }(), - expectValidationError: true, - }, - { - scenario: "valid quality form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "quality": { - "50", - }, - }) - return ctx - }(), - expectedOptions: func() ScreenshotOptions { - options := DefaultScreenshotOptions() - options.Quality = 50 - return options - }(), - expectValidationError: false, - }, - { - scenario: "custom form fields (Options & ScreenshotOptions)", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "width": { - "1280", - }, - "height": { - "800", - }, - "clip": { - "true", - }, - "optimizeForSpeed": { - "true", - }, - "emulatedMediaType": { - "screen", - }, - }) - return ctx - }(), - expectedOptions: func() ScreenshotOptions { - options := DefaultScreenshotOptions() - options.Width = 1280 - options.Height = 800 - options.Clip = true - options.OptimizeForSpeed = true - options.EmulatedMediaType = "screen" - return options - }(), - expectValidationError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - form, actual := FormDataChromiumScreenshotOptions(tc.ctx.Context) - - if !reflect.DeepEqual(actual, tc.expectedOptions) { - t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, 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) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - api Api - expectError bool - expectHttpError bool - expectHttpStatus int - expectOutputPathsCount int - }{ - { - scenario: "missing mandatory url form field", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "empty url form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "url": { - "", - }, - }) - return ctx - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "error from Chromium", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "url": { - "foo", - }, - }) - return ctx - }(), - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return errors.New("foo") - }}, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "url": { - "foo", - }, - }) - return ctx - }(), - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return nil - }}, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - c := echo.New().NewContext(nil, nil) - c.Set("context", tc.ctx.Context) - - err := convertUrlRoute(tc.api, nil).Handler(c) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } - }) - } -} - -func TestScreenshotUrlRoute(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - api Api - expectError bool - expectHttpError bool - expectHttpStatus int - expectOutputPathsCount int - }{ - { - scenario: "missing mandatory url form field", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "empty url form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "url": { - "", - }, - }) - return ctx - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "error from Chromium", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "url": { - "foo", - }, - }) - return ctx - }(), - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return errors.New("foo") - }}, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetValues(map[string][]string{ - "url": { - "foo", - }, - }) - return ctx - }(), - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return nil - }}, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - c := echo.New().NewContext(nil, nil) - c.Set("context", tc.ctx.Context) - - err := screenshotUrlRoute(tc.api).Handler(c) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } - }) - } -} - -func TestConvertHtmlRoute(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - api Api - expectError bool - expectHttpError bool - expectHttpStatus int - expectOutputPathsCount int - }{ - { - scenario: "missing mandatory index.html form file", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "error from Chromium", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "index.html": "/index.html", - }) - return ctx - }(), - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return errors.New("foo") - }}, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "index.html": "/index.html", - }) - return ctx - }(), - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return nil - }}, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - c := echo.New().NewContext(nil, nil) - c.Set("context", tc.ctx.Context) - - err := convertHtmlRoute(tc.api, nil).Handler(c) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } - }) - } -} - -func TestScreenshotHtmlRoute(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - api Api - expectError bool - expectHttpError bool - expectHttpStatus int - expectOutputPathsCount int - }{ - { - scenario: "missing mandatory index.html form file", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "error from Chromium", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "index.html": "/index.html", - }) - return ctx - }(), - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return errors.New("foo") - }}, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "index.html": "/index.html", - }) - return ctx - }(), - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return nil - }}, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - c := echo.New().NewContext(nil, nil) - c.Set("context", tc.ctx.Context) - - err := screenshotHtmlRoute(tc.api).Handler(c) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } - }) - } -} - -func TestConvertMarkdownRoute(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - api Api - expectError bool - expectHttpError bool - expectHttpStatus int - expectOutputPathsCount int - }{ - { - scenario: "missing mandatory index.html form file", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "missing mandatory markdown form files", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "index.html": "/index.html", - }) - return ctx - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "markdown file requested in index.html not found", - ctx: func() *api.ContextMock { - dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString()) - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetDirPath(dirPath) - ctx.SetFiles(map[string]string{ - "index.html": fmt.Sprintf("%s/index.html", dirPath), - "wrong_name.md": fmt.Sprintf("%s/wrong_name.md", dirPath), - }) - - err := os.MkdirAll(dirPath, 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("
{{ toHTML \"markdown.md\" }}
"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return ctx - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "non-existing markdown file", - ctx: func() *api.ContextMock { - dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString()) - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetDirPath(dirPath) - ctx.SetFiles(map[string]string{ - "index.html": fmt.Sprintf("%s/index.html", dirPath), - "markdown.md": fmt.Sprintf("%s/markdown.md", dirPath), - }) - - err := os.MkdirAll(dirPath, 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("
{{ toHTML \"markdown.md\" }}
"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return ctx - }(), - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "error from Chromium", - ctx: func() *api.ContextMock { - dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString()) - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetDirPath(dirPath) - ctx.SetFiles(map[string]string{ - "index.html": fmt.Sprintf("%s/index.html", dirPath), - "markdown.md": fmt.Sprintf("%s/markdown.md", dirPath), - }) - - err := os.MkdirAll(dirPath, 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("
{{ toHTML \"markdown.md\" }}
"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - err = os.WriteFile(fmt.Sprintf("%s/markdown.md", dirPath), []byte("# Hello World!"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return ctx - }(), - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return errors.New("foo") - }}, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success", - ctx: func() *api.ContextMock { - dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString()) - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetDirPath(dirPath) - ctx.SetFiles(map[string]string{ - "index.html": fmt.Sprintf("%s/index.html", dirPath), - "markdown.md": fmt.Sprintf("%s/markdown.md", dirPath), - }) - - err := os.MkdirAll(dirPath, 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("
{{ toHTML \"markdown.md\" }}
"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - err = os.WriteFile(fmt.Sprintf("%s/markdown.md", dirPath), []byte("# Hello World!"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return ctx - }(), - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return nil - }}, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - if tc.ctx.DirPath() != "" { - defer func() { - err := os.RemoveAll(tc.ctx.DirPath()) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - } - - tc.ctx.SetLogger(zap.NewNop()) - c := echo.New().NewContext(nil, nil) - c.Set("context", tc.ctx.Context) - - err := convertMarkdownRoute(tc.api, nil).Handler(c) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } - }) - } -} - -func TestScreenshotMarkdownRoute(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - api Api - expectError bool - expectHttpError bool - expectHttpStatus int - expectOutputPathsCount int - }{ - { - scenario: "missing mandatory index.html form file", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "missing mandatory markdown form files", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "index.html": "/index.html", - }) - return ctx - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "markdown file requested in index.html not found", - ctx: func() *api.ContextMock { - dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString()) - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetDirPath(dirPath) - ctx.SetFiles(map[string]string{ - "index.html": fmt.Sprintf("%s/index.html", dirPath), - "wrong_name.md": fmt.Sprintf("%s/wrong_name.md", dirPath), - }) - - err := os.MkdirAll(dirPath, 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("
{{ toHTML \"markdown.md\" }}
"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return ctx - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "non-existing markdown file", - ctx: func() *api.ContextMock { - dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString()) - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetDirPath(dirPath) - ctx.SetFiles(map[string]string{ - "index.html": fmt.Sprintf("%s/index.html", dirPath), - "markdown.md": fmt.Sprintf("%s/markdown.md", dirPath), - }) - - err := os.MkdirAll(dirPath, 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("
{{ toHTML \"markdown.md\" }}
"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return ctx - }(), - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "error from Chromium", - ctx: func() *api.ContextMock { - dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString()) - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetDirPath(dirPath) - ctx.SetFiles(map[string]string{ - "index.html": fmt.Sprintf("%s/index.html", dirPath), - "markdown.md": fmt.Sprintf("%s/markdown.md", dirPath), - }) - - err := os.MkdirAll(dirPath, 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("
{{ toHTML \"markdown.md\" }}
"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - err = os.WriteFile(fmt.Sprintf("%s/markdown.md", dirPath), []byte("# Hello World!"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return ctx - }(), - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return errors.New("foo") - }}, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success", - ctx: func() *api.ContextMock { - dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString()) - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetDirPath(dirPath) - ctx.SetFiles(map[string]string{ - "index.html": fmt.Sprintf("%s/index.html", dirPath), - "markdown.md": fmt.Sprintf("%s/markdown.md", dirPath), - }) - - err := os.MkdirAll(dirPath, 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("
{{ toHTML \"markdown.md\" }}
"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - err = os.WriteFile(fmt.Sprintf("%s/markdown.md", dirPath), []byte("# Hello World!"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return ctx - }(), - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return nil - }}, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - if tc.ctx.DirPath() != "" { - defer func() { - err := os.RemoveAll(tc.ctx.DirPath()) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - } - - tc.ctx.SetLogger(zap.NewNop()) - c := echo.New().NewContext(nil, nil) - c.Set("context", tc.ctx.Context) - - err := screenshotMarkdownRoute(tc.api).Handler(c) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } - }) - } -} - -func TestConvertUrl(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - api Api - engine gotenberg.PdfEngine - options PdfOptions - pdfFormats gotenberg.PdfFormats - metadata map[string]interface{} - expectError bool - expectHttpError bool - expectHttpStatus int - expectOutputPathsCount int - }{ - { - scenario: "ErrOmitBackgroundWithoutPrintBackground", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return ErrOmitBackgroundWithoutPrintBackground - }}, - options: DefaultPdfOptions(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrInvalidEvaluationExpression (without waitForExpression form field)", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return ErrInvalidEvaluationExpression - }}, - options: DefaultPdfOptions(), - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrInvalidEvaluationExpression (with waitForExpression form field)", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return ErrInvalidEvaluationExpression - }}, - options: func() PdfOptions { - options := DefaultPdfOptions() - options.WaitForExpression = "foo" - - return options - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrInvalidPrinterSettings", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return ErrInvalidPrinterSettings - }}, - options: DefaultPdfOptions(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrPageRangesSyntaxError", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return ErrPageRangesSyntaxError - }}, - options: DefaultPdfOptions(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrInvalidHttpStatusCode", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return ErrInvalidHttpStatusCode - }}, - options: DefaultPdfOptions(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusConflict, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrInvalidResourceHttpStatusCode", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return ErrInvalidResourceHttpStatusCode - }}, - options: DefaultPdfOptions(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusConflict, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrConsoleExceptions", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return ErrConsoleExceptions - }}, - options: DefaultPdfOptions(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusConflict, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrLoadingFailed", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return ErrLoadingFailed - }}, - options: DefaultPdfOptions(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrResourceLoadingFailed", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return ErrResourceLoadingFailed - }}, - options: DefaultPdfOptions(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusConflict, - expectOutputPathsCount: 0, - }, - { - scenario: "error from Chromium", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return errors.New("foo") - }}, - options: DefaultPdfOptions(), - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "PDF engine convert 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{ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return errors.New("foo") - }}, - options: DefaultPdfOptions(), - pdfFormats: gotenberg.PdfFormats{PdfA: "foo"}, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success with 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{ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return nil - }}, - options: DefaultPdfOptions(), - pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b}, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - { - scenario: "PDF engine write metadata 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{WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { - return errors.New("foo") - }}, - options: DefaultPdfOptions(), - metadata: map[string]interface{}{ - "Creator": "foo", - "Producer": "bar", - }, - expectError: true, - expectHttpError: false, - }, - { - scenario: "cannot add output paths", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetCancelled(true) - return ctx - }(), - api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { - return nil - }}, - options: DefaultPdfOptions(), - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success", - 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{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return nil - }, - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { - return nil - }, - }, - options: DefaultPdfOptions(), - pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b}, - metadata: map[string]interface{}{ - "Creator": "foo", - "Producer": "bar", - }, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - err := convertUrl(tc.ctx.Context, tc.api, tc.engine, "", tc.options, tc.pdfFormats, tc.metadata) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } - }) - } -} - -func TestScreenshotUrl(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - api Api - options ScreenshotOptions - expectError bool - expectHttpError bool - expectHttpStatus int - expectOutputPathsCount int - }{ - { - scenario: "ErrInvalidEvaluationExpression (without waitForExpression form field)", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return ErrInvalidEvaluationExpression - }}, - options: DefaultScreenshotOptions(), - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrInvalidEvaluationExpression (with waitForExpression form field)", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return ErrInvalidEvaluationExpression - }}, - options: func() ScreenshotOptions { - options := DefaultScreenshotOptions() - options.WaitForExpression = "foo" - - return options - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrInvalidHttpStatusCode", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return ErrInvalidHttpStatusCode - }}, - options: DefaultScreenshotOptions(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusConflict, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrInvalidResourceHttpStatusCode", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return ErrInvalidResourceHttpStatusCode - }}, - options: DefaultScreenshotOptions(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusConflict, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrConsoleExceptions", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return ErrConsoleExceptions - }}, - options: DefaultScreenshotOptions(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusConflict, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrLoadingFailed", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return ErrLoadingFailed - }}, - options: DefaultScreenshotOptions(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrResourceLoadingFailed", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return ErrResourceLoadingFailed - }}, - options: DefaultScreenshotOptions(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusConflict, - expectOutputPathsCount: 0, - }, - { - scenario: "error from Chromium", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return errors.New("foo") - }}, - options: DefaultScreenshotOptions(), - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "cannot add output paths", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetCancelled(true) - return ctx - }(), - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return nil - }}, - options: DefaultScreenshotOptions(), - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success", - ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { - return nil - }}, - options: DefaultScreenshotOptions(), - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - err := screenshotUrl(tc.ctx.Context, tc.api, "", tc.options) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } - }) - } -} diff --git a/pkg/modules/chromium/stream.go b/pkg/modules/chromium/stream.go index 70d205764..23092a40f 100644 --- a/pkg/modules/chromium/stream.go +++ b/pkg/modules/chromium/stream.go @@ -24,7 +24,7 @@ type streamReader struct { // Read a chunk of the stream. func (reader *streamReader) Read(p []byte) (n int, err error) { if reader.r != nil { - // Continue reading from buffer. + // Continue reading from the buffer. return reader.read(p) } diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go index 5f19a2884..13e004323 100644 --- a/pkg/modules/chromium/tasks.go +++ b/pkg/modules/chromium/tasks.go @@ -49,9 +49,8 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti WithPageRanges(pageRanges). WithPreferCSSPageSize(options.PreferCssPageSize). WithGenerateDocumentOutline(options.GenerateDocumentOutline). - // Does not seem to work. - // See https://github.com/gotenberg/gotenberg/issues/831. - WithGenerateTaggedPDF(false) + // See https://github.com/gotenberg/gotenberg/issues/1210. + WithGenerateTaggedPDF(options.GenerateTaggedPdf) hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate || options.FooterTemplate != DefaultPdfOptions().FooterTemplate @@ -331,7 +330,7 @@ func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent boo return func(ctx context.Context) error { logger.Debug(fmt.Sprintf("navigate to '%s'", url)) - _, _, _, err := page.Navigate(url).Do(ctx) + _, _, _, _, err := page.Navigate(url).Do(ctx) if err != nil { return fmt.Errorf("navigate to '%s': %w", url, err) } @@ -392,26 +391,30 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr } } -func forceExactColorsActionFunc() chromedp.ActionFunc { +func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chromedp.ActionFunc { return func(ctx context.Context) error { - // See: - // https://github.com/gotenberg/gotenberg/issues/354 - // https://github.com/puppeteer/puppeteer/issues/2685 - // https://github.com/chromedp/chromedp/issues/520 - script := ` -(() => { - const css = 'html { -webkit-print-color-adjust: exact !important; }'; + css := "html { -webkit-print-color-adjust: exact !important; }" + if !printBackground { + // The -webkit-print-color-adjust: exact CSS property forces the + // print of the background, whatever the printToPDF args. + // See https://github.com/gotenberg/gotenberg/issues/1154. + additionalCss := "html, body { background: none !important; }" + logger.Debug(fmt.Sprintf("inject %s as printBackground is %t", additionalCss, printBackground)) + css += additionalCss + } + script := fmt.Sprintf(` +(() => { + const css = '%s'; const style = document.createElement('style'); style.type = 'text/css'; style.appendChild(document.createTextNode(css)); document.head.appendChild(style); })(); -` +`, css) evaluate := chromedp.Evaluate(script, nil) err := evaluate.Do(ctx) - if err == nil { return nil } diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index 7d2cb8d97..3b4294f8a 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -5,7 +5,10 @@ import ( "errors" "fmt" "os" + "os/exec" "reflect" + "strings" + "syscall" "github.com/barasher/go-exiftool" "go.uber.org/zap" @@ -53,11 +56,38 @@ func (engine *ExifTool) Validate() error { return nil } +// Debug returns additional debug data. +func (engine *ExifTool) Debug() map[string]interface{} { + debug := make(map[string]interface{}) + + cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + debug["version"] = err.Error() + return debug + } + + debug["version"] = strings.TrimSpace(string(output)) + return debug +} + // Merge is not available in this implementation. func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Split is not available in this implementation. +func (engine *ExifTool) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// Flatten is not available in this implementation. +func (engine *ExifTool) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { + return fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Convert is not available in this implementation. func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) @@ -112,15 +142,15 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m fileMetadata[0].SetStrings(key, val) case []interface{}: // See https://github.com/gotenberg/gotenberg/issues/1048. - strings := make([]string, len(val)) + strs := make([]string, len(val)) for i, entry := range val { if str, ok := entry.(string); ok { - strings[i] = str + strs[i] = str continue } return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported) } - fileMetadata[0].SetStrings(key, strings) + fileMetadata[0].SetStrings(key, strs) case bool: fileMetadata[0].SetString(key, fmt.Sprintf("%t", val)) case int: @@ -146,10 +176,26 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m return nil } +// Encrypt is not available in this implementation. +func (engine *ExifTool) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported) +} + +// EmbedFiles is not available in this implementation. +func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { + return fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// ImportBookmarks is not available in this implementation. +func (engine *ExifTool) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error { + return fmt.Errorf("import bookmarks into PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Interface guards. var ( _ gotenberg.Module = (*ExifTool)(nil) _ gotenberg.Provisioner = (*ExifTool)(nil) _ gotenberg.Validator = (*ExifTool)(nil) + _ gotenberg.Debuggable = (*ExifTool)(nil) _ gotenberg.PdfEngine = (*ExifTool)(nil) ) diff --git a/pkg/modules/exiftool/exiftool_test.go b/pkg/modules/exiftool/exiftool_test.go deleted file mode 100644 index 949087ec8..000000000 --- a/pkg/modules/exiftool/exiftool_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package exiftool - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "reflect" - "testing" - - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestExifTool_Descriptor(t *testing.T) { - descriptor := new(ExifTool).Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(ExifTool)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestExifTool_Provision(t *testing.T) { - engine := new(ExifTool) - ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil) - - err := engine.Provision(ctx) - if err != nil { - t.Errorf("expected no error but got: %v", err) - } -} - -func TestExifTool_Validate(t *testing.T) { - for _, tc := range []struct { - scenario string - binPath string - expectError bool - }{ - { - scenario: "empty bin path", - binPath: "", - expectError: true, - }, - { - scenario: "bin path does not exist", - binPath: "/foo", - expectError: true, - }, - { - scenario: "validate success", - binPath: os.Getenv("EXIFTOOL_BIN_PATH"), - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - engine := new(ExifTool) - engine.binPath = tc.binPath - err := engine.Validate() - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestExiftool_Merge(t *testing.T) { - engine := new(ExifTool) - err := engine.Merge(context.Background(), zap.NewNop(), nil, "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} - -func TestExiftool_Convert(t *testing.T) { - engine := new(ExifTool) - err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} - -func TestExiftool_ReadMetadata(t *testing.T) { - for _, tc := range []struct { - scenario string - inputPath string - expectMetadata map[string]interface{} - expectError bool - }{ - { - scenario: "invalid input path", - inputPath: "foo", - expectMetadata: nil, - expectError: true, - }, - { - scenario: "success", - inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", - expectMetadata: map[string]interface{}{ - "FileName": "sample1.pdf", - "FileTypeExtension": "pdf", - "MIMEType": "application/pdf", - "PDFVersion": 1.4, - "PageCount": float64(3), - }, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - engine := new(ExifTool) - err := engine.Provision(nil) - if err != nil { - t.Fatalf("expected error but got: %v", err) - } - - metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), tc.inputPath) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - - if tc.expectMetadata != nil && err == nil { - for k, v := range tc.expectMetadata { - if v2, ok := metadata[k]; !ok || v != v2 { - t.Errorf("expected entry %s with value %v to exists", k, v) - } - } - } - }) - } -} - -func TestExiftool_WriteMetadata(t *testing.T) { - for _, tc := range []struct { - scenario string - createCopy bool - inputPath string - metadata map[string]interface{} - expectMetadata map[string]interface{} - expectError bool - expectedError error - }{ - { - scenario: "invalid input path", - createCopy: false, - inputPath: "foo", - expectError: true, - }, - { - scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported (not string array)", - createCopy: true, - inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", - metadata: map[string]interface{}{ - "Unsupported": []interface{}{ - "foo", - 1, - }, - }, - expectError: true, - expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported, - }, - { - scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported (default)", - createCopy: true, - inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", - metadata: map[string]interface{}{ - "Unsupported": map[string]interface{}{}, - }, - expectError: true, - expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported, - }, - { - scenario: "success (interface array to string array)", - createCopy: true, - inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", - metadata: map[string]interface{}{ - "Keywords": []interface{}{ - "first", - "second", - }, - }, - expectMetadata: map[string]interface{}{ - "Keywords": []interface{}{ - "first", - "second", - }, - }, - expectError: false, - }, - { - scenario: "success", - createCopy: true, - inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", - metadata: map[string]interface{}{ - "Author": "Julien Neuhart", - "Copyright": "Julien Neuhart", - "CreationDate": "2006-09-18T16:27:50-04:00", - "Creator": "Gotenberg", - "Keywords": []string{ - "first", - "second", - }, - "Marked": true, - "ModDate": "2006-09-18T16:27:50-04:00", - "PDFVersion": 1.7, - "Producer": "Gotenberg", - "Subject": "Sample", - "Title": "Sample", - "Trapped": "Unknown", - // Those are not valid PDF metadata. - "int": 1, - "int64": int64(2), - "float32": float32(2.2), - "float64": 3.3, - }, - expectMetadata: map[string]interface{}{ - "Author": "Julien Neuhart", - "Copyright": "Julien Neuhart", - "CreationDate": "2006:09:18 16:27:50-04:00", - "Creator": "Gotenberg", - "Keywords": []interface{}{ - "first", - "second", - }, - "Marked": true, - "ModDate": "2006:09:18 16:27:50-04:00", - "PDFVersion": 1.7, - "Producer": "Gotenberg", - "Subject": "Sample", - "Title": "Sample", - "Trapped": "Unknown", - }, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - engine := new(ExifTool) - err := engine.Provision(nil) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var destinationPath string - if tc.createCopy { - fs := gotenberg.NewFileSystem() - 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.WriteMetadata(context.Background(), zap.NewNop(), tc.metadata, 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") - } - - if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { - t.Fatalf("expected error %v but got: %v", tc.expectedError, err) - } - - if tc.expectError { - return - } - - metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), destinationPath) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectMetadata != nil && err == nil { - for k, v := range tc.expectMetadata { - v2, ok := metadata[k] - if !ok { - t.Errorf("expected entry %s with value %v to exists, but got none", k, v) - continue - } - - switch v2.(type) { - case []interface{}: - for i, entry := range v.([]interface{}) { - if entry != v2.([]interface{})[i] { - t.Errorf("expected entry %s to contain value %v, but got %v", k, entry, v2.([]interface{})[i]) - } - } - default: - if v != v2 { - t.Errorf("expected entry %s with value %v to exists, but got %v", k, v, v2) - } - } - } - } - }) - } -} diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go index e32712cff..2e0529b46 100644 --- a/pkg/modules/libreoffice/api/api.go +++ b/pkg/modules/libreoffice/api/api.go @@ -5,6 +5,9 @@ import ( "errors" "fmt" "os" + "os/exec" + "strings" + "syscall" "time" "github.com/alexliesenfeld/health" @@ -21,23 +24,23 @@ func init() { } var ( - // ErrInvalidPdfFormats happens if the PDF formats option cannot be handled - // by LibreOffice. + // ErrInvalidPdfFormats happens if LibreOffice cannot handle the PDF + // formats option. ErrInvalidPdfFormats = errors.New("invalid PDF formats") - // ErrUnoException happens when unoconverter returns an exit code 5. + // ErrUnoException happens when unoconverter returns exit code 5. ErrUnoException = errors.New("uno exception") - // ErrRuntimeException happens when unoconverter returns an exit code 6. + // ErrRuntimeException happens when unoconverter returns exit code 6. ErrRuntimeException = errors.New("uno exception") - // ErrCoreDumped happens randomly; sometime a conversion will work as + // ErrCoreDumped happens randomly; sometimes a conversion will work as // expected, and some other time the same conversion will fail. // See https://github.com/gotenberg/gotenberg/issues/639. ErrCoreDumped = errors.New("core dumped") ) -// Api is a module which provides a [Uno] to interact with LibreOffice. +// Api is a module that provides a [Uno] to interact with LibreOffice. type Api struct { autoStart bool args libreOfficeArguments @@ -53,12 +56,17 @@ type Options struct { // Password specifies the password for opening the source file. Password string - // Landscape allows to change the orientation of the resulting PDF. + // Landscape allows changing the orientation of the resulting PDF. Landscape bool - // PageRanges allows to select the pages to convert. + // PageRanges allows selecting the pages to convert. PageRanges string + // UpdateIndexes specifies whether to update the indexes before conversion, + // keeping in mind that doing so might result in missing links in the final + // PDF. + UpdateIndexes bool + // ExportFormFields specifies whether form fields are exported as widgets // or only their fixed print representation is exported. ExportFormFields bool @@ -75,7 +83,7 @@ type Options struct { // Named Destination. ExportBookmarksToPdfDestination bool - // ExportPlaceholders exports the placeholders fields visual markings only. + // ExportPlaceholders exports the placeholder fields visual markings only. // The exported placeholder is ineffective. ExportPlaceholders bool @@ -86,15 +94,16 @@ type Options struct { // Notes pages are available in Impress documents only. ExportNotesPages bool - // ExportOnlyNotesPages specifies, if the property ExportNotesPages is set - // to true, if only notes pages are exported to PDF. + // ExportOnlyNotesPages specifies if the property ExportNotesPages is set + // to true if only notes pages are exported to PDF. ExportOnlyNotesPages bool - // ExportNotesInMargin specifies if notes in margin are exported to PDF. + // ExportNotesInMargin specifies if notes in the margin are exported to + // PDF. ExportNotesInMargin bool // ConvertOooTargetToPdfTarget specifies that the target documents with - // .od[tpgs] extension, will have that extension changed to .pdf when the + // .od[tpgs] extension will have that extension changed to .pdf when the // link is exported to PDF. The source document remains untouched. ConvertOooTargetToPdfTarget bool @@ -149,6 +158,7 @@ func DefaultOptions() Options { Password: "", Landscape: false, PageRanges: "", + UpdateIndexes: true, ExportFormFields: true, AllowDuplicateFieldNames: false, ExportBookmarks: true, @@ -181,7 +191,7 @@ type Uno interface { Extensions() []string } -// Provider is a module interface which exposes a method for creating a +// Provider is a module interface that exposes a method for creating a // [Uno] for other modules. // // func (m *YourModule) Provision(ctx *gotenberg.Context) error { @@ -291,8 +301,8 @@ func (a *Api) StartupMessage() string { // Stop stops the current browser instance. func (a *Api) Stop(ctx context.Context) error { - // Block until the context is done so that other module may gracefully stop - // before we do a shutdown. + // Block until the context is done so that another module may gracefully + // stop before we do a shutdown. a.logger.Debug("wait for the end of grace duration") <-ctx.Done() @@ -305,6 +315,23 @@ func (a *Api) Stop(ctx context.Context) error { return fmt.Errorf("stop LibreOffice: %w", err) } +// Debug returns additional debug data. +func (a *Api) Debug() map[string]interface{} { + debug := make(map[string]interface{}) + + cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + debug["version"] = err.Error() + return debug + } + + debug["version"] = strings.TrimSpace(string(output)) + return debug +} + // Metrics returns the metrics. func (a *Api) Metrics() ([]gotenberg.Metric, error) { return []gotenberg.Metric{ @@ -536,6 +563,7 @@ var ( _ gotenberg.Provisioner = (*Api)(nil) _ gotenberg.Validator = (*Api)(nil) _ gotenberg.App = (*Api)(nil) + _ gotenberg.Debuggable = (*Api)(nil) _ gotenberg.MetricsProvider = (*Api)(nil) _ api.HealthChecker = (*Api)(nil) _ Uno = (*Api)(nil) diff --git a/pkg/modules/libreoffice/api/api_test.go b/pkg/modules/libreoffice/api/api_test.go deleted file mode 100644 index b9480b852..000000000 --- a/pkg/modules/libreoffice/api/api_test.go +++ /dev/null @@ -1,474 +0,0 @@ -package api - -import ( - "context" - "errors" - "os" - "reflect" - "testing" - "time" - - "github.com/alexliesenfeld/health" - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestDefaultOptions(t *testing.T) { - actual := DefaultOptions() - notExpect := Options{} - - if reflect.DeepEqual(actual, notExpect) { - t.Errorf("expected %v and got identical %v", actual, notExpect) - } -} - -func TestApi_Descriptor(t *testing.T) { - descriptor := new(Api).Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(Api)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestApi_Provision(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *gotenberg.Context - expectError bool - }{ - { - scenario: "no logger provider", - ctx: func() *gotenberg.Context { - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Api).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{}, - ) - }(), - expectError: true, - }, - { - scenario: "no logger from logger provider", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - gotenberg.LoggerProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} - } - mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { - return nil, errors.New("foo") - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Api).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "provision success", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - gotenberg.LoggerProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} - } - mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { - return zap.NewNop(), nil - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Api).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - a := new(Api) - err := a.Provision(tc.ctx) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestApi_Validate(t *testing.T) { - for _, tc := range []struct { - scenario string - binPath string - unoBinPath string - expectError bool - }{ - { - scenario: "empty LibreOffice bin path", - binPath: "", - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - expectError: true, - }, - { - scenario: "LibreOffice bin path does not exist", - binPath: "/foo", - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - expectError: true, - }, - { - scenario: "empty uno bin path", - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - unoBinPath: "", - expectError: true, - }, - { - scenario: "uno bin path does not exist", - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - unoBinPath: "/foo", - expectError: true, - }, - { - scenario: "validate success", - binPath: os.Getenv("CHROMIUM_BIN_PATH"), - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - a := new(Api) - a.args = libreOfficeArguments{ - binPath: tc.binPath, - unoBinPath: tc.unoBinPath, - } - err := a.Validate() - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestApi_Start(t *testing.T) { - for _, tc := range []struct { - scenario string - autoStart bool - supervisor *gotenberg.ProcessSupervisorMock - expectError bool - }{ - { - scenario: "no auto-start", - autoStart: false, - expectError: false, - }, - { - scenario: "auto-start success", - autoStart: true, - supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error { - return nil - }}, - expectError: false, - }, - { - scenario: "auto-start failed", - autoStart: true, - supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error { - return errors.New("foo") - }}, - expectError: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - a := new(Api) - a.autoStart = tc.autoStart - a.supervisor = tc.supervisor - - err := a.Start() - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestApi_StartupMessage(t *testing.T) { - a := new(Api) - - a.autoStart = true - autoStartMsg := a.StartupMessage() - - a.autoStart = false - noAutoStartMsg := a.StartupMessage() - - if autoStartMsg == noAutoStartMsg { - t.Errorf("expected differrent startup messages based on auto start, but got '%s'", autoStartMsg) - } -} - -func TestApi_Stop(t *testing.T) { - for _, tc := range []struct { - scenario string - supervisor *gotenberg.ProcessSupervisorMock - expectError bool - }{ - { - scenario: "stop success", - supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error { - return nil - }}, - expectError: false, - }, - { - scenario: "stop failed", - supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error { - return errors.New("foo") - }}, - expectError: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - a := new(Api) - a.logger = zap.NewNop() - a.supervisor = tc.supervisor - - ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second) - cancel() - - err := a.Stop(ctx) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestApi_Metrics(t *testing.T) { - a := new(Api) - a.supervisor = &gotenberg.ProcessSupervisorMock{ - ReqQueueSizeMock: func() int64 { - return 10 - }, - RestartsCountMock: func() int64 { - return 0 - }, - } - - metrics, err := a.Metrics() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if len(metrics) != 2 { - t.Fatalf("expected %d metrics, but got %d", 2, len(metrics)) - } - - actual := metrics[0].Read() - if actual != float64(10) { - t.Errorf("expected %f for libreoffice_requests_queue_size, but got %f", float64(10), actual) - } - - actual = metrics[1].Read() - if actual != float64(0) { - t.Errorf("expected %f for libreoffice_restarts_count, but got %f", float64(0), actual) - } -} - -func TestApi_Checks(t *testing.T) { - for _, tc := range []struct { - scenario string - supervisor gotenberg.ProcessSupervisor - expectAvailabilityStatus health.AvailabilityStatus - }{ - { - scenario: "healthy module", - supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool { - return true - }}, - expectAvailabilityStatus: health.StatusUp, - }, - { - scenario: "unhealthy module", - supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool { - return false - }}, - expectAvailabilityStatus: health.StatusDown, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - a := new(Api) - a.supervisor = tc.supervisor - - checks, err := a.Checks() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - checker := health.NewChecker(checks...) - result := checker.Check(context.Background()) - - if result.Status != tc.expectAvailabilityStatus { - t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status) - } - }) - } -} - -func TestChromium_Ready(t *testing.T) { - for _, tc := range []struct { - scenario string - autoStart bool - startTimeout time.Duration - libreOffice libreOffice - expectError bool - }{ - { - scenario: "no auto-start", - autoStart: false, - startTimeout: time.Duration(30) * time.Second, - libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool { - return false - }}}, - expectError: false, - }, - { - scenario: "auto-start: context done", - autoStart: true, - startTimeout: time.Duration(200) * time.Millisecond, - libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool { - return false - }}}, - expectError: true, - }, - { - scenario: "auto-start success", - autoStart: true, - startTimeout: time.Duration(30) * time.Second, - libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool { - return true - }}}, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - a := new(Api) - a.autoStart = tc.autoStart - a.args = libreOfficeArguments{startTimeout: tc.startTimeout} - a.libreOffice = tc.libreOffice - - err := a.Ready() - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestApi_LibreOffice(t *testing.T) { - a := new(Api) - - _, err := a.LibreOffice() - if err != nil { - t.Errorf("expected no error but got: %v", err) - } -} - -func TestApi_Pdf(t *testing.T) { - for _, tc := range []struct { - scenario string - supervisor gotenberg.ProcessSupervisor - libreOffice libreOffice - expectError bool - }{ - { - scenario: "PDF task success", - libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error { - return nil - }}, - expectError: false, - }, - { - scenario: "PDF task error", - libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error { - return errors.New("PDF task error") - }}, - expectError: true, - }, - { - scenario: "ErrCoreDumped", - libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error { - return ErrCoreDumped - }}, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - a := new(Api) - a.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error { - return task() - }} - a.libreOffice = tc.libreOffice - - err := a.Pdf(context.Background(), zap.NewNop(), "", "", Options{}) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestApi_Extensions(t *testing.T) { - a := new(Api) - extensions := a.Extensions() - - actual := len(extensions) - expect := 130 - - if actual != expect { - t.Errorf("expected %d extensions, but got %d", expect, actual) - } -} diff --git a/pkg/modules/libreoffice/api/libreoffice.go b/pkg/modules/libreoffice/api/libreoffice.go index f8c4415b2..9ee9e3531 100644 --- a/pkg/modules/libreoffice/api/libreoffice.go +++ b/pkg/modules/libreoffice/api/libreoffice.go @@ -4,16 +4,13 @@ import ( "context" "errors" "fmt" - "io" "net" "os" - "path/filepath" "strings" "sync" "sync/atomic" "time" - "github.com/google/uuid" "go.uber.org/zap" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" @@ -44,7 +41,7 @@ type libreOfficeProcess struct { func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice { p := &libreOfficeProcess{ arguments: arguments, - fs: gotenberg.NewFileSystem(), + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), } p.isStarted.Store(false) @@ -190,22 +187,23 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error { // Always remove the user profile directory created by LibreOffice. copyUserProfileDirPath := p.userProfileDirPath - defer func(userProfileDirPath string) { + expirationTime := time.Now() + defer func(userProfileDirPath string, expirationTime time.Time) { go func() { err := os.RemoveAll(userProfileDirPath) if err != nil { logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err)) + } else { + logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath)) } - logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath)) - - // Also remove LibreOffice specific files in the temporary directory. - err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}) + // Also, remove LibreOffice specific files in the temporary directory. + err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime) if err != nil { logger.Error(err.Error()) } }() - }(copyUserProfileDirPath) + }(copyUserProfileDirPath, expirationTime) p.cfgMu.Lock() defer p.cfgMu.Unlock() @@ -274,10 +272,15 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP args = append(args, "--printer", "PaperOrientation=landscape") } + // See: https://github.com/gotenberg/gotenberg/issues/1149. if options.PageRanges != "" { args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges)) } + if !options.UpdateIndexes { + args = append(args, "--disable-update-indexes") + } + args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields)) args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames)) args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks)) @@ -327,11 +330,6 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP ) } - inputPath, err := nonBasicLatinCharactersGuard(logger, inputPath) - if err != nil { - return fmt.Errorf("non-basic latin characters guard: %w", err) - } - args = append(args, "--output", outputPath, inputPath) cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.unoBinPath, args...) @@ -347,10 +345,10 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP } // LibreOffice's errors are not explicit. - // For instance, an exit code 5 may be explained by a malformed page - // ranges, but also by a not required password. + // For instance, exit code 5 may be explained by a malformed page range + // but also by a not required password. - // We may want to retry in case of a core dumped event. + // We may want to retry in case of a core-dumped event. // See https://github.com/gotenberg/gotenberg/issues/639. if strings.Contains(err.Error(), "core dumped") { return ErrCoreDumped @@ -368,65 +366,6 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP return fmt.Errorf("convert to PDF: %w", err) } -// LibreOffice cannot convert a file with a name containing non-basic Latin -// characters. -// See: -// https://github.com/gotenberg/gotenberg/issues/104 -// https://github.com/gotenberg/gotenberg/issues/730 -func nonBasicLatinCharactersGuard(logger *zap.Logger, inputPath string) (string, error) { - hasNonBasicLatinChars := func(str string) bool { - for _, r := range str { - // Check if the character is outside basic Latin. - if r != '.' && (r < ' ' || r > '~') { - return true - } - } - return false - } - - filename := filepath.Base(inputPath) - if !hasNonBasicLatinChars(filename) { - logger.Debug("no non-basic latin characters in filename, skip copy") - return inputPath, nil - } - - logger.Warn("non-basic latin characters in filename, copy to a file with a valid filename") - basePath := filepath.Dir(inputPath) - ext := filepath.Ext(inputPath) - newInputPath := filepath.Join(basePath, fmt.Sprintf("%s%s", uuid.NewString(), ext)) - - in, err := os.Open(inputPath) - if err != nil { - return "", fmt.Errorf("open file: %w", err) - } - - defer func() { - err := in.Close() - if err != nil { - logger.Error(fmt.Sprintf("close file: %s", err)) - } - }() - - out, err := os.Create(newInputPath) - if err != nil { - return "", fmt.Errorf("create new file: %w", err) - } - - defer func() { - err := out.Close() - if err != nil { - logger.Error(fmt.Sprintf("close new file: %s", err)) - } - }() - - _, err = io.Copy(out, in) - if err != nil { - return "", fmt.Errorf("copy file to new file: %w", err) - } - - return newInputPath, nil -} - // Interface guards. var ( _ gotenberg.Process = (*libreOfficeProcess)(nil) diff --git a/pkg/modules/libreoffice/api/libreoffice_test.go b/pkg/modules/libreoffice/api/libreoffice_test.go deleted file mode 100644 index 953cb908b..000000000 --- a/pkg/modules/libreoffice/api/libreoffice_test.go +++ /dev/null @@ -1,699 +0,0 @@ -package api - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "testing" - "time" - - "github.com/google/uuid" - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestLibreOfficeProcess_Start(t *testing.T) { - for _, tc := range []struct { - scenario string - libreOffice libreOffice - expectError bool - cleanup bool - }{ - { - scenario: "successful start", - libreOffice: newLibreOfficeProcess( - libreOfficeArguments{ - binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - startTimeout: 5 * time.Second, - }, - ), - expectError: false, - cleanup: true, - }, - { - scenario: "LibreOffice already started", - libreOffice: func() libreOffice { - p := new(libreOfficeProcess) - p.isStarted.Store(true) - return p - }(), - expectError: true, - cleanup: false, - }, - { - scenario: "non-exit code 81 on first start", - libreOffice: newLibreOfficeProcess( - libreOfficeArguments{ - binPath: "foo", - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - startTimeout: 5 * time.Second, - }, - ), - expectError: true, - cleanup: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() - err := tc.libreOffice.Start(logger) - - if tc.cleanup { - defer func(p libreOffice, logger *zap.Logger) { - err = p.Stop(logger) - if err != nil { - t.Fatalf("expected no error while cleaning up, but got: %v", err) - } - }(tc.libreOffice, logger) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestLibreOfficeProcess_Stop(t *testing.T) { - for _, tc := range []struct { - scenario string - libreOffice libreOffice - setup func(libreOffice libreOffice, logger *zap.Logger) error - expectError bool - }{ - { - scenario: "successful stop", - libreOffice: newLibreOfficeProcess( - libreOfficeArguments{ - binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - startTimeout: 5 * time.Second, - }, - ), - setup: func(p libreOffice, logger *zap.Logger) error { - return p.Start(logger) - }, - expectError: false, - }, - { - scenario: "LibreOffice already stopped", - libreOffice: func() libreOffice { - p := new(libreOfficeProcess) - p.isStarted.Store(false) - return p - }(), - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() - - if tc.setup != nil { - err := tc.setup(tc.libreOffice, logger) - if err != nil { - t.Fatalf("setup error: %v", err) - } - } - - err := tc.libreOffice.Stop(logger) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestLibreOfficeProcess_Healthy(t *testing.T) { - for _, tc := range []struct { - scenario string - libreOffice libreOffice - setup func(libreOffice libreOffice, logger *zap.Logger) error - expectHealthy bool - cleanup bool - }{ - { - scenario: "healthy LibreOffice", - libreOffice: newLibreOfficeProcess( - libreOfficeArguments{ - binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - startTimeout: 5 * time.Second, - }, - ), - setup: func(p libreOffice, logger *zap.Logger) error { - return p.Start(logger) - }, - expectHealthy: true, - cleanup: true, - }, - { - scenario: "LibreOffice not started", - libreOffice: func() libreOffice { - p := new(libreOfficeProcess) - p.isStarted.Store(false) - return p - }(), - expectHealthy: false, - cleanup: false, - }, - { - scenario: "unhealthy LibreOffice", - libreOffice: func() libreOffice { - p := new(libreOfficeProcess) - p.isStarted.Store(true) - p.socketPort = 12345 - return p - }(), - expectHealthy: false, - cleanup: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() - - if tc.setup != nil { - err := tc.setup(tc.libreOffice, logger) - if err != nil { - t.Fatalf("setup error: %v", err) - } - } - - if tc.cleanup { - defer func(p libreOffice, logger *zap.Logger) { - err := p.Stop(logger) - if err != nil { - t.Fatalf("expected no error while cleaning up, but got: %v", err) - } - }(tc.libreOffice, logger) - } - - healthy := tc.libreOffice.Healthy(logger) - - if !tc.expectHealthy && healthy { - t.Fatal("expected unhealthy LibreOffice but got an healthy one") - } - - if tc.expectHealthy && !healthy { - t.Fatal("expected a healthy LibreOffice but got an unhealthy one") - } - }) - } -} - -func TestLibreOfficeProcess_pdf(t *testing.T) { - for _, tc := range []struct { - scenario string - libreOffice libreOffice - fs *gotenberg.FileSystem - options Options - cancelledCtx bool - start bool - expectError bool - expectedError error - }{ - { - scenario: "LibreOffice not started", - libreOffice: func() libreOffice { - p := new(libreOfficeProcess) - p.isStarted.Store(false) - return p - }(), - fs: gotenberg.NewFileSystem(), - cancelledCtx: false, - start: false, - expectError: true, - }, - { - scenario: "ErrInvalidPdfFormats", - libreOffice: func() libreOffice { - p := new(libreOfficeProcess) - p.socketPort = 12345 - p.isStarted.Store(true) - return p - }(), - fs: gotenberg.NewFileSystem(), - options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: "foo"}}, - cancelledCtx: false, - start: false, - expectError: true, - expectedError: ErrInvalidPdfFormats, - }, - { - scenario: "ErrUnoException", - libreOffice: newLibreOfficeProcess( - libreOfficeArguments{ - binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - startTimeout: 5 * time.Second, - }, - ), - options: Options{PageRanges: "foo"}, - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - cancelledCtx: false, - start: true, - expectError: true, - expectedError: ErrUnoException, - }, - { - scenario: "ErrRuntimeException", - libreOffice: newLibreOfficeProcess( - libreOfficeArguments{ - binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - startTimeout: 5 * time.Second, - }, - ), - options: Options{Password: "foo"}, - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - in, err := os.Open("/tests/test/testdata/libreoffice/protected.docx") - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - defer func() { - err := in.Close() - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - }() - - out, err := os.Create(fmt.Sprintf("%s/protected.docx", fs.WorkingDirPath())) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - defer func() { - err := out.Close() - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - }() - - _, err = io.Copy(out, in) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - return fs - }(), - cancelledCtx: false, - start: true, - expectError: true, - expectedError: ErrRuntimeException, - }, - { - scenario: "context done", - libreOffice: newLibreOfficeProcess( - libreOfficeArguments{ - binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - startTimeout: 5 * time.Second, - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - cancelledCtx: true, - start: true, - expectError: true, - }, - { - scenario: "success (default options)", - libreOffice: newLibreOfficeProcess( - libreOfficeArguments{ - binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - startTimeout: 5 * time.Second, - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - cancelledCtx: false, - start: true, - expectError: false, - }, - { - scenario: "success (not default options)", - libreOffice: newLibreOfficeProcess( - libreOfficeArguments{ - binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - startTimeout: 5 * time.Second, - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: Options{ - Password: "", // Ok, the only exception in this list. - Landscape: true, - PageRanges: "1", - ExportFormFields: false, - AllowDuplicateFieldNames: true, - ExportBookmarks: false, - ExportBookmarksToPdfDestination: true, - ExportPlaceholders: true, - ExportNotes: true, - ExportNotesPages: true, - ExportOnlyNotesPages: true, - ExportNotesInMargin: true, - ConvertOooTargetToPdfTarget: true, - ExportLinksRelativeFsys: true, - ExportHiddenSlides: true, - SkipEmptyPages: true, - AddOriginalDocumentAsStream: true, - SinglePageSheets: true, - LosslessImageCompression: true, - Quality: 100, - ReduceImageResolution: true, - MaxImageResolution: 600, - }, - cancelledCtx: false, - start: true, - expectError: false, - }, - { - scenario: "success (PDF/A-1b)", - libreOffice: newLibreOfficeProcess( - libreOfficeArguments{ - binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - startTimeout: 5 * time.Second, - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b}}, - cancelledCtx: false, - start: true, - expectError: false, - }, - { - scenario: "success (PDF/A-2b)", - libreOffice: newLibreOfficeProcess( - libreOfficeArguments{ - binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - startTimeout: 5 * time.Second, - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA2b}}, - cancelledCtx: false, - start: true, - expectError: false, - }, - { - scenario: "success (PDF/A-3b)", - libreOffice: newLibreOfficeProcess( - libreOfficeArguments{ - binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - startTimeout: 5 * time.Second, - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA3b}}, - cancelledCtx: false, - start: true, - expectError: false, - }, - { - scenario: "success (PDF/UA)", - libreOffice: newLibreOfficeProcess( - libreOfficeArguments{ - binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), - startTimeout: 5 * time.Second, - }, - ), - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - options: Options{PdfFormats: gotenberg.PdfFormats{PdfUa: true}}, - cancelledCtx: false, - start: true, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - // Force the debug level. - logger := zap.NewExample() - - defer func() { - err := os.RemoveAll(tc.fs.WorkingDirPath()) - if err != nil { - t.Fatalf("expected no error while cleaning up, but got: %v", err) - } - }() - - if tc.start { - err := tc.libreOffice.Start(logger) - if err != nil { - t.Fatalf("setup error: %v", err) - } - - defer func(p libreOffice, logger *zap.Logger) { - err = p.Stop(logger) - if err != nil { - t.Fatalf("expected no error while cleaning up, but got: %v", err) - } - }(tc.libreOffice, logger) - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(5)*time.Second) - defer cancel() - - if tc.cancelledCtx { - cancel() - } - - err := tc.libreOffice.pdf( - ctx, - logger, - fmt.Sprintf("%s/document.txt", tc.fs.WorkingDirPath()), - fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()), - tc.options, - ) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - - if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { - t.Fatalf("expected error %v but got: %v", tc.expectedError, err) - } - }) - } -} - -func TestNonBasicLatinCharactersGuard(t *testing.T) { - for _, tc := range []struct { - scenario string - fs *gotenberg.FileSystem - filename string - expectSameInputPath bool - expectError bool - }{ - { - scenario: "basic latin characters", - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Basic latin characters"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - filename: "document.txt", - expectSameInputPath: true, - expectError: false, - }, - { - scenario: "non-basic latin characters", - fs: func() *gotenberg.FileSystem { - fs := gotenberg.NewFileSystem() - - err := os.MkdirAll(fs.WorkingDirPath(), 0o755) - if err != nil { - t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) - } - - err = os.WriteFile(fmt.Sprintf("%s/รฉรจรŸร รนรค.txt", fs.WorkingDirPath()), []byte("Non-basic latin characters"), 0o755) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return fs - }(), - filename: "รฉรจรŸร รนรค.txt", - expectSameInputPath: false, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - defer func() { - err := os.RemoveAll(tc.fs.WorkingDirPath()) - if err != nil { - t.Fatalf("expected no error while cleaning up, but got: %v", err) - } - }() - - inputPath := fmt.Sprintf("%s/%s", tc.fs.WorkingDirPath(), tc.filename) - newInputPath, err := nonBasicLatinCharactersGuard( - zap.NewNop(), - inputPath, - ) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - - if tc.expectSameInputPath && newInputPath != inputPath { - t.Fatalf("expected same input path, but got '%s'", newInputPath) - } - - if !tc.expectSameInputPath && newInputPath == inputPath { - t.Fatalf("expected different input path, but got same '%s'", newInputPath) - } - }) - } -} diff --git a/pkg/modules/libreoffice/api/mocks_test.go b/pkg/modules/libreoffice/api/mocks_test.go deleted file mode 100644 index f2a03530f..000000000 --- a/pkg/modules/libreoffice/api/mocks_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package api - -import ( - "context" - "testing" - - "go.uber.org/zap" -) - -func TestApiMock(t *testing.T) { - mock := &ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error { - return nil - }, - ExtensionsMock: func() []string { - return nil - }, - } - - err := mock.Pdf(context.Background(), zap.NewNop(), "", "", Options{}) - if err != nil { - t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err) - } - - ext := mock.Extensions() - if ext != nil { - t.Errorf("expected nil result from ApiMock.Extensions, but got: %v", ext) - } -} - -func TestProviderMock(t *testing.T) { - mock := &ProviderMock{ - LibreOfficeMock: func() (Uno, error) { - return nil, nil - }, - } - - _, err := mock.LibreOffice() - if err != nil { - t.Errorf("expected no error from ProviderMock.LibreOffice, but got: %v", err) - } -} - -func TestLibreOfficeMock(t *testing.T) { - for _, tc := range []struct { - scenario string - mock *libreOfficeMock - expectError bool - }{ - { - scenario: "success", - mock: &libreOfficeMock{ - pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error { - return nil - }, - }, - expectError: false, - }, - { - scenario: "ErrCoreDumped (first call)", - mock: &libreOfficeMock{ - pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error { - return ErrCoreDumped - }, - }, - expectError: true, - }, - { - scenario: "ErrCoreDumped (second call)", - mock: func() *libreOfficeMock { - m := &libreOfficeMock{ - pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error { - return ErrCoreDumped - }, - } - m.pdf(context.Background(), zap.NewNop(), "", "", Options{}) - return m - }(), - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - err := tc.mock.pdf(context.Background(), zap.NewNop(), "", "", Options{}) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error from libreOfficeMock.pdf but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error from libreOfficeMock.pdf but got none") - } - }) - } -} diff --git a/pkg/modules/libreoffice/libreoffice.go b/pkg/modules/libreoffice/libreoffice.go index 6dd04b82e..fe6573394 100644 --- a/pkg/modules/libreoffice/libreoffice.go +++ b/pkg/modules/libreoffice/libreoffice.go @@ -14,7 +14,7 @@ func init() { gotenberg.MustRegisterModule(new(LibreOffice)) } -// LibreOffice is a module which provides a route for converting documents to +// LibreOffice is a module that provides a route for converting documents to // PDF with LibreOffice. type LibreOffice struct { api libeofficeapi.Uno diff --git a/pkg/modules/libreoffice/libreoffice_test.go b/pkg/modules/libreoffice/libreoffice_test.go deleted file mode 100644 index 4d756a079..000000000 --- a/pkg/modules/libreoffice/libreoffice_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package libreoffice - -import ( - "errors" - "reflect" - "testing" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" - libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api" -) - -func TestLibreOffice_Descriptor(t *testing.T) { - descriptor := new(LibreOffice).Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(LibreOffice)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestLibreOffice_Provision(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *gotenberg.Context - expectError bool - }{ - { - scenario: "no LibreOffice API provider", - ctx: func() *gotenberg.Context { - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(LibreOffice).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{}, - ) - }(), - expectError: true, - }, - { - scenario: "no LibreOffice API from LibreOffice API provider", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - libreofficeapi.ProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} - } - mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) { - return nil, errors.New("foo") - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(LibreOffice).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "no PDF engine provider", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - libreofficeapi.ProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} - } - mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) { - return new(libreofficeapi.ApiMock), nil - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(LibreOffice).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "no PDF engine from PDF engine provider", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - libreofficeapi.ProviderMock - gotenberg.PdfEngineProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} - } - mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) { - return new(libreofficeapi.ApiMock), nil - } - mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) { - return nil, errors.New("foo") - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(LibreOffice).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "provision success", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - libreofficeapi.ProviderMock - gotenberg.PdfEngineProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} - } - mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) { - return new(libreofficeapi.ApiMock), nil - } - mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) { - return new(gotenberg.PdfEngineMock), nil - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(LibreOffice).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(LibreOffice) - err := mod.Provision(tc.ctx) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestLibreOffice_Routes(t *testing.T) { - for _, tc := range []struct { - scenario string - expectRoutes int - disableRoutes bool - }{ - { - scenario: "routes not disabled", - expectRoutes: 1, - disableRoutes: false, - }, - { - scenario: "routes disabled", - expectRoutes: 0, - disableRoutes: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(LibreOffice) - mod.disableRoutes = tc.disableRoutes - - routes, err := mod.Routes() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectRoutes != len(routes) { - t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes)) - } - }) - } -} diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index b478c21be..3205aaaaa 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -51,6 +51,16 @@ func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logge return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Split is not available in this implementation. +func (engine *LibreOfficePdfEngine) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, fmt.Errorf("split PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// Flatten is not available in this implementation. +func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { + return fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Convert converts the given PDF to a specific PDF format. Currently, only the // PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another // PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported] @@ -81,6 +91,21 @@ func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *z return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Encrypt is not available in this implementation. +func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// EmbedFiles is not available in this implementation. +func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { + return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// ImportBookmarks is not available in this implementation. +func (engine *LibreOfficePdfEngine) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error { + return fmt.Errorf("import bookmarks into PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Interface guards. var ( _ gotenberg.Module = (*LibreOfficePdfEngine)(nil) diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go deleted file mode 100644 index 8353954d6..000000000 --- a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package pdfengine - -import ( - "context" - "errors" - "reflect" - "testing" - - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" - "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api" -) - -func TestLibreOfficePdfEngine_Descriptor(t *testing.T) { - descriptor := new(LibreOfficePdfEngine).Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(LibreOfficePdfEngine)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestLibreOfficePdfEngine_Provider(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *gotenberg.Context - expectError bool - }{ - { - scenario: "no LibreOffice API provider", - ctx: gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{}, - ), - expectError: true, - }, - { - scenario: "no API from LibreOffice API provider", - ctx: func() *gotenberg.Context { - provider := &struct { - gotenberg.ModuleMock - api.ProviderMock - }{} - provider.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { - return provider - }} - } - provider.LibreOfficeMock = func() (api.Uno, error) { - return nil, errors.New("foo") - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - provider.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "provision success", - ctx: func() *gotenberg.Context { - provider := &struct { - gotenberg.ModuleMock - api.ProviderMock - }{} - provider.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { - return provider - }} - } - provider.LibreOfficeMock = func() (api.Uno, error) { - return new(api.ApiMock), nil - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - provider.Descriptor(), - }, - ) - }(), - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - engine := new(LibreOfficePdfEngine) - err := engine.Provision(tc.ctx) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestLibreOfficePdfEngine_Merge(t *testing.T) { - engine := new(LibreOfficePdfEngine) - err := engine.Merge(context.Background(), zap.NewNop(), nil, "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} - -func TestLibreOfficePdfEngine_Convert(t *testing.T) { - for _, tc := range []struct { - scenario string - api api.Uno - expectError bool - }{ - { - scenario: "convert success", - api: &api.ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error { - return nil - }, - }, - expectError: false, - }, - { - scenario: "invalid PDF format", - api: &api.ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error { - return api.ErrInvalidPdfFormats - }, - }, - expectError: true, - }, - { - scenario: "convert fail", - api: &api.ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error { - return errors.New("foo") - }, - }, - expectError: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - engine := &LibreOfficePdfEngine{unoApi: tc.api} - err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) { - engine := new(LibreOfficePdfEngine) - _, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} - -func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) { - engine := new(LibreOfficePdfEngine) - err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index b49677d64..854f347ab 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -1,7 +1,6 @@ package libreoffice import ( - "encoding/json" "errors" "fmt" "net/http" @@ -28,14 +27,20 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap defaultOptions := libreofficeapi.DefaultOptions() form := ctx.FormData() + splitMode := pdfengines.FormDataPdfSplitMode(form, false) pdfFormats := pdfengines.FormDataPdfFormats(form) - metadata := pdfengines.FormDataPdfMetadata(form) + metadata := pdfengines.FormDataPdfMetadata(form, false) + userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) + embedPaths := pdfengines.FormDataPdfEmbeds(form) + + zeroValuedSplitMode := gotenberg.SplitMode{} var ( inputPaths []string password string landscape bool nativePageRanges string + updateIndexes bool exportFormFields bool allowDuplicateFieldNames bool exportBookmarks bool @@ -57,6 +62,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap maxImageResolution int nativePdfFormats bool merge bool + flatten bool ) err := form. @@ -64,6 +70,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap String("password", &password, defaultOptions.Password). Bool("landscape", &landscape, defaultOptions.Landscape). String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges). + Bool("updateIndexes", &updateIndexes, defaultOptions.UpdateIndexes). Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields). Bool("allowDuplicateFieldNames", &allowDuplicateFieldNames, defaultOptions.AllowDuplicateFieldNames). Bool("exportBookmarks", &exportBookmarks, defaultOptions.ExportBookmarks). @@ -123,15 +130,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap }). Bool("nativePdfFormats", &nativePdfFormats, true). Bool("merge", &merge, 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 - }). + Bool("flatten", &flatten, false). Validate() if err != nil { return fmt.Errorf("validate form data: %w", err) @@ -144,6 +143,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap Password: password, Landscape: landscape, PageRanges: nativePageRanges, + UpdateIndexes: updateIndexes, ExportFormFields: exportFormFields, AllowDuplicateFieldNames: allowDuplicateFieldNames, ExportBookmarks: exportBookmarks, @@ -165,7 +165,9 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap MaxImageResolution: maxImageResolution, } - if nativePdfFormats { + if nativePdfFormats && splitMode == zeroValuedSplitMode { + // Only natively apply given PDF formats if we're not + // splitting the PDF later. options.PdfFormats = pdfFormats } @@ -209,11 +211,51 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap outputPaths = []string{outputPath} } - if !nativePdfFormats { - outputPaths, err = pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths) + if splitMode != zeroValuedSplitMode { + if !merge { + // document.docx -> document.docx.pdf, so that split naming + // document.docx_0.pdf, etc. + for i, inputPath := range inputPaths { + outputPath := fmt.Sprintf("%s.pdf", inputPath) + + err = ctx.Rename(outputPaths[i], outputPath) + if err != nil { + return fmt.Errorf("rename output path: %w", err) + } + + outputPaths[i] = outputPath + } + } + + outputPaths, err = pdfengines.SplitPdfStub(ctx, engine, splitMode, outputPaths) + if err != nil { + return fmt.Errorf("split PDFs: %w", err) + } + } + + 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.EmbedFilesStub(ctx, engine, embedPaths, outputPaths) + if err != nil { + return fmt.Errorf("embed files into PDFs: %w", err) } err = pdfengines.WriteMetadataStub(ctx, engine, metadata, outputPaths) @@ -221,7 +263,19 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap return fmt.Errorf("write metadata: %w", err) } - if len(outputPaths) > 1 { + if flatten { + err = pdfengines.FlattenStub(ctx, engine, outputPaths) + if err != nil { + return fmt.Errorf("flatten PDFs: %w", err) + } + } + + err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) + if err != nil { + return fmt.Errorf("encrypt PDFs: %w", err) + } + + if len(outputPaths) > 1 && splitMode == zeroValuedSplitMode { // If .zip archive, document.docx -> document.docx.pdf. for i, inputPath := range inputPaths { outputPath := fmt.Sprintf("%s.pdf", inputPath) diff --git a/pkg/modules/libreoffice/routes_test.go b/pkg/modules/libreoffice/routes_test.go deleted file mode 100644 index 041e41655..000000000 --- a/pkg/modules/libreoffice/routes_test.go +++ /dev/null @@ -1,598 +0,0 @@ -package libreoffice - -import ( - "context" - "errors" - "net/http" - "slices" - "testing" - - "github.com/labstack/echo/v4" - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" - "github.com/gotenberg/gotenberg/v8/pkg/modules/api" - libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api" -) - -func TestConvertRoute(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - libreOffice libreofficeapi.Uno - engine gotenberg.PdfEngine - expectOptions libreofficeapi.Options - expectError bool - expectHttpError bool - expectHttpStatus int - expectOutputPathsCount int - expectOutputPaths []string - }{ - { - scenario: "missing at least one mandatory file", - ctx: &api.ContextMock{Context: new(api.Context)}, - libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string { - return []string{".docx"} - }}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "invalid quality form field (not an integer)", - 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{ - "quality": { - "foo", - }, - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string { - return []string{".docx"} - }}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "invalid quality form field (< 1)", - 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{ - "quality": { - "0", - }, - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string { - return []string{".docx"} - }}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "invalid quality form field (> 100)", - 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{ - "quality": { - "101", - }, - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string { - return []string{".docx"} - }}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "invalid maxImageResolution form field (not an integer)", - 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{ - "maxImageResolution": { - "foo", - }, - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string { - return []string{".docx"} - }}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "invalid maxImageResolution form field (not in range)", - 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{ - "maxImageResolution": { - "1", - }, - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string { - return []string{".docx"} - }}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "invalid metadata form field", - 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{ - "metadata": { - "foo", - }, - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string { - return []string{".docx"} - }}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrPdfFormatNotSupported (nativePdfFormats)", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "document.docx": "/document.docx", - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { - return libreofficeapi.ErrInvalidPdfFormats - }, - ExtensionsMock: func() []string { - return []string{".docx"} - }, - }, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrUnoException", - 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{ - "nativePageRanges": { - "foo", - }, - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { - return libreofficeapi.ErrUnoException - }, - ExtensionsMock: func() []string { - return []string{".docx"} - }, - }, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "ErrRuntimeException", - 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{ - "password": { - "invalid", - }, - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { - return libreofficeapi.ErrRuntimeException - }, - ExtensionsMock: func() []string { - return []string{".docx"} - }, - }, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "error from LibreOffice", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "document.docx": "/document.docx", - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { - return errors.New("foo") - }, - ExtensionsMock: func() []string { - return []string{".docx"} - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "PDF engine merge error", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "document.docx": "/document.docx", - "document2.docx": "/document2.docx", - }) - ctx.SetValues(map[string][]string{ - "merge": { - "true", - }, - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { - return nil - }, - ExtensionsMock: func() []string { - return []string{".docx"} - }, - }, - engine: &gotenberg.PdfEngineMock{ - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return errors.New("foo") - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "PDF engine convert 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{ - "pdfa": { - gotenberg.PdfA1b, - }, - "nativePdfFormats": { - "false", - }, - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { - return nil - }, - ExtensionsMock: func() []string { - return []string{".docx"} - }, - }, - engine: &gotenberg.PdfEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return errors.New("foo") - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "PDF engine write metadata 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{ - "metadata": { - "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", - }, - }) - 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{ - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { - return errors.New("foo") - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "cannot rename 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", - "document2.doc": "/document2.doc", - }) - ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { - return errors.New("cannot rename") - }}) - 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", ".doc"} - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "cannot add output paths", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "document.docx": "/document.docx", - }) - ctx.SetCancelled(true) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { - return nil - }, - ExtensionsMock: func() []string { - return []string{".docx"} - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success (single file)", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "document.docx": "/document.docx", - "document2.docx": "/document2.docx", - }) - ctx.SetValues(map[string][]string{ - "quality": { - "100", - }, - "maxImageResolution": { - "1200", - }, - "merge": { - "true", - }, - "pdfa": { - gotenberg.PdfA1b, - }, - "pdfua": { - "true", - }, - "nativePdfFormats": { - "false", - }, - "metadata": { - "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", - }, - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { - return nil - }, - ExtensionsMock: func() []string { - return []string{".docx"} - }, - }, - engine: &gotenberg.PdfEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return nil - }, - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return nil - }, - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { - return nil - }, - }, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - { - scenario: "success (many files)", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "document.docx": "/document.docx", - "document2.docx": "/document2.docx", - "document2.doc": "/document2.doc", - }) - ctx.SetValues(map[string][]string{ - "pdfa": { - gotenberg.PdfA1b, - }, - "pdfua": { - "true", - }, - "nativePdfFormats": { - "false", - }, - "metadata": { - "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", - }, - }) - ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { - return nil - }}) - 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", ".doc"} - }, - }, - engine: &gotenberg.PdfEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return nil - }, - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return nil - }, - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { - return nil - }, - }, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 3, - expectOutputPaths: []string{"/document.docx.pdf", "/document2.docx.pdf", "/document2.doc.pdf"}, - }, - { - scenario: "success with native PDF/A & PDF/UA", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "document.docx": "/document.docx", - }) - ctx.SetValues(map[string][]string{ - "pdfa": { - gotenberg.PdfA1b, - }, - "pdfua": { - "true", - }, - }) - return ctx - }(), - libreOffice: &libreofficeapi.ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { - return nil - }, - ExtensionsMock: func() []string { - return []string{".docx"} - }, - }, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - c := echo.New().NewContext(nil, nil) - c.Set("context", tc.ctx.Context) - - err := convertRoute(tc.libreOffice, tc.engine).Handler(c) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } - - for _, path := range tc.expectOutputPaths { - if !slices.Contains(tc.ctx.OutputPaths(), path) { - t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths()) - } - } - }) - } -} diff --git a/pkg/modules/logging/color.go b/pkg/modules/logging/color.go new file mode 100644 index 000000000..e9a9d97b3 --- /dev/null +++ b/pkg/modules/logging/color.go @@ -0,0 +1,49 @@ +package logging + +import ( + "fmt" + + "go.uber.org/zap/zapcore" +) + +// Foreground colors. +// Copy pasted from go.uber.org/zap/internal/color/color.go +const ( + black color = iota + 30 + red + green + yellow + blue + magenta + cyan + white +) + +type color uint8 + +func (c color) Add(s string) string { + return fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(c), s) +} + +func levelToColor(l zapcore.Level) color { + switch l { + case zapcore.DebugLevel: + return cyan + case zapcore.InfoLevel: + return blue + case zapcore.WarnLevel: + return yellow + case zapcore.ErrorLevel: + return red + case zapcore.DPanicLevel: + return red + case zapcore.PanicLevel: + return red + case zapcore.FatalLevel: + return red + case zapcore.InvalidLevel: + return red + default: + return red + } +} diff --git a/pkg/modules/logging/gcp.go b/pkg/modules/logging/gcp.go new file mode 100644 index 000000000..e9ddc9f60 --- /dev/null +++ b/pkg/modules/logging/gcp.go @@ -0,0 +1,36 @@ +package logging + +import "go.uber.org/zap/zapcore" + +func gcpSeverity(l zapcore.Level) string { + switch l { + case zapcore.DebugLevel: + return "DEBUG" + case zapcore.InfoLevel: + return "INFO" + case zapcore.WarnLevel: + return "WARNING" + case zapcore.ErrorLevel: + return "ERROR" + case zapcore.DPanicLevel: + return "CRITICAL" + case zapcore.PanicLevel: + return "ALERT" + case zapcore.FatalLevel: + return "EMERGENCY" + case zapcore.InvalidLevel: + return "DEFAULT" + default: + return "DEFAULT" + } +} + +func gcpSeverityEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) { + enc.AppendString(gcpSeverity(l)) +} + +func gcpSeverityColorEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) { + severity := gcpSeverity(l) + c := levelToColor(l) + enc.AppendString(c.Add(severity)) +} diff --git a/pkg/modules/logging/logging.go b/pkg/modules/logging/logging.go index d9d80023d..91f591b14 100644 --- a/pkg/modules/logging/logging.go +++ b/pkg/modules/logging/logging.go @@ -31,12 +31,13 @@ const ( textLoggingFormat = "text" ) -// Logging is a module which implements the [gotenberg.LoggerProvider] +// Logging is a module that implements the [gotenberg.LoggerProvider] // interface. type Logging struct { - level string - format string - fieldsPrefix string + level string + format string + fieldsPrefix string + enableGcpFields bool } // Descriptor returns a [Logging]'s module descriptor. @@ -48,6 +49,14 @@ func (log *Logging) Descriptor() gotenberg.ModuleDescriptor { fs.String("log-level", infoLoggingLevel, fmt.Sprintf("Choose the level of logging detail. Options include %s, %s, %s, or %s", errorLoggingLevel, warnLoggingLevel, infoLoggingLevel, debugLoggingLevel)) fs.String("log-format", autoLoggingFormat, fmt.Sprintf("Specify the format of logging. Options include %s, %s, or %s", autoLoggingFormat, jsonLoggingFormat, textLoggingFormat)) fs.String("log-fields-prefix", "", "Prepend a specified prefix to each field in the logs") + fs.Bool("log-enable-gcp-fields", false, "Enable Google Cloud Platform fields - namely: time, message, severity") + + // Deprecated flags. + fs.Bool("log-enable-gcp-severity", false, "Enable Google Cloud Platform severity mapping") + err := fs.MarkDeprecated("log-enable-gcp-severity", "use log-enable-gcp-fields instead") + if err != nil { + panic(err) + } return fs }(), @@ -62,6 +71,7 @@ func (log *Logging) Provision(ctx *gotenberg.Context) error { log.level = flags.MustString("log-level") log.format = flags.MustString("log-format") log.fieldsPrefix = flags.MustString("log-fields-prefix") + log.enableGcpFields = flags.MustDeprecatedBool("log-enable-gcp-severity", "log-enable-gcp-fields") return nil } @@ -101,7 +111,7 @@ func (log *Logging) Logger(mod gotenberg.Module) (*zap.Logger, error) { return nil, fmt.Errorf("get log level: %w", err) } - encoder, err := newLogEncoder(log.format) + encoder, err := newLogEncoder(log.format, log.enableGcpFields) if err != nil { return nil, fmt.Errorf("get log encoder: %w", err) } @@ -166,26 +176,44 @@ func newLogLevel(level string) (zapcore.Level, error) { return lvl, nil } -func newLogEncoder(format string) (zapcore.Encoder, error) { +func newLogEncoder(format string, gcpFields bool) (zapcore.Encoder, error) { isTerminal := term.IsTerminal(int(os.Stdout.Fd())) encCfg := zap.NewProductionEncoderConfig() + // Normalize the log format based on the output device. + if format == autoLoggingFormat { + if isTerminal { + format = textLoggingFormat + } else { + format = jsonLoggingFormat + } + } + + // Use a human-readable time format if running in a terminal. if isTerminal { - // If interactive terminal, make output more human-readable by default. - // Credits: https://github.com/caddyserver/caddy/blob/v2.1.1/logging.go#L671. encCfg.EncodeTime = func(ts time.Time, encoder zapcore.PrimitiveArrayEncoder) { encoder.AppendString(ts.Local().Format("2006/01/02 15:04:05.000")) } + } - if format == textLoggingFormat || format == autoLoggingFormat { + // Configure level encoding based on format and GCP settings. + if format == textLoggingFormat && isTerminal { + if gcpFields { + encCfg.EncodeLevel = gcpSeverityColorEncoder + } else { encCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder } } - if format == autoLoggingFormat && isTerminal { - format = textLoggingFormat - } else if format == autoLoggingFormat { - format = jsonLoggingFormat + // For non-text (JSON) or when GCP fields are requested outside a terminal text output, + // adjust the configuration to use GCP-specific field names and encoders. + if gcpFields && format != textLoggingFormat { + encCfg.EncodeLevel = gcpSeverityEncoder + encCfg.TimeKey = "time" + encCfg.LevelKey = "severity" + encCfg.MessageKey = "message" + encCfg.EncodeTime = zapcore.ISO8601TimeEncoder + encCfg.EncodeDuration = zapcore.MillisDurationEncoder } switch format { diff --git a/pkg/modules/logging/logging_test.go b/pkg/modules/logging/logging_test.go deleted file mode 100644 index 8d484328b..000000000 --- a/pkg/modules/logging/logging_test.go +++ /dev/null @@ -1,347 +0,0 @@ -package logging - -import ( - "fmt" - "reflect" - "testing" - - "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "go.uber.org/zap/zaptest/observer" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestLogging_Descriptor(t *testing.T) { - descriptor := new(Logging).Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(Logging)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestLogging_Provision(t *testing.T) { - for _, tc := range []struct { - scenario string - level string - format string - fieldsPrefix string - expectLevel string - expectFormat string - expectFieldsPrefix string - }{ - { - scenario: "default values", - expectLevel: infoLoggingLevel, - expectFormat: autoLoggingFormat, - expectFieldsPrefix: "", - }, - { - scenario: "explicit values", - level: "debug", - format: "json", - fieldsPrefix: "gotenberg", - expectLevel: debugLoggingLevel, - expectFormat: jsonLoggingFormat, - expectFieldsPrefix: "gotenberg", - }, - { - scenario: "wrong values", // no validation at this point. - level: "foo", - format: "foo", - expectLevel: "foo", - expectFormat: "foo", - expectFieldsPrefix: "", - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - var flags []string - - if tc.level != "" { - flags = append(flags, "--log-level", tc.level) - } - - if tc.format != "" { - flags = append(flags, "--log-format", tc.format) - } - - if tc.fieldsPrefix != "" { - flags = append(flags, "--log-fields-prefix", tc.fieldsPrefix) - } - - logging := new(Logging) - fs := logging.Descriptor().FlagSet - - err := fs.Parse(flags) - if err != nil { - t.Fatalf("expected no error while parsing flags but got: %v", err) - } - - ctx := gotenberg.NewContext(gotenberg.ParsedFlags{FlagSet: fs}, nil) - - err = logging.Provision(ctx) - if err != nil { - t.Fatalf("expected no error while provisioning but got: %v", err) - } - - if logging.level != tc.expectLevel { - t.Errorf("expected logging level '%s' but got '%s'", tc.expectLevel, logging.level) - } - - if logging.format != tc.expectFormat { - t.Errorf("expected logging format '%s' but got '%s'", tc.expectFormat, logging.format) - } - - if logging.fieldsPrefix != tc.expectFieldsPrefix { - t.Errorf("expected logging fields prefix '%s' but got '%s'", tc.expectFieldsPrefix, logging.fieldsPrefix) - } - }) - } -} - -func TestLogging_Validate(t *testing.T) { - for _, tc := range []struct { - scenario string - level string - format string - expectError bool - }{ - { - scenario: "invalid level", - level: "foo", - expectError: true, - }, - { - scenario: "invalid format", - level: debugLoggingLevel, - format: "foo", - expectError: true, - }, - { - scenario: "valid level and format", - level: debugLoggingLevel, - format: autoLoggingFormat, - }, - } { - logging := new(Logging) - logging.level = tc.level - logging.format = tc.format - - err := logging.Validate() - - if tc.expectError && err == nil { - t.Errorf("%s: expected error but got: %v", tc.scenario, err) - } - - if !tc.expectError && err != nil { - t.Errorf("%s: expected no error but got: %v", tc.scenario, err) - } - } -} - -func TestLogging_Logger(t *testing.T) { - for _, tc := range []struct { - scenario string - level string - format string - fieldsPrefix string - expectError bool - }{ - { - scenario: "invalid level", - level: "foo", - expectError: true, - }, - { - scenario: "invalid format", - level: debugLoggingLevel, - format: "foo", - expectError: true, - }, - { - scenario: "valid level and format", - level: debugLoggingLevel, - format: autoLoggingFormat, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - logging := new(Logging) - logging.level = tc.level - logging.format = tc.format - logging.fieldsPrefix = tc.fieldsPrefix - - _, err := logging.Logger(&gotenberg.ModuleMock{ - DescriptorMock: func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "mock", New: nil} - }, - }) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }) - } -} - -func TestCustomCore(t *testing.T) { - for _, tc := range []struct { - scenario string - level zapcore.Level - fieldsPrefix string - expectEntry bool - }{ - { - scenario: "level enabled", - level: zapcore.DebugLevel, - fieldsPrefix: "gotenberg", - expectEntry: true, - }, - { - scenario: "no fields prefix", - level: zapcore.DebugLevel, - expectEntry: true, - }, - { - scenario: "level disabled", - level: zapcore.ErrorLevel, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - core, obsvr := observer.New(tc.level) - lgr := zap.New(customCore{ - Core: core, - fieldsPrefix: tc.fieldsPrefix, - }).With(zap.String("a_field", "a value")) - - lgr.Debug("a debug message", zap.String("another_field", "another value")) - - entries := obsvr.TakeAll() - - if tc.expectEntry && len(entries) == 0 { - t.Fatal("expected an entry") - } - - if !tc.expectEntry && len(entries) != 0 { - t.Fatal("expected no entry") - } - - var prefix string - if tc.fieldsPrefix != "" { - prefix = tc.fieldsPrefix + "_" - } - - for _, entry := range entries { - fields := entry.Context - - if len(fields) != 2 { - t.Fatalf("expected 2 fields but got %d", len(fields)) - } - - if fields[0].Key != fmt.Sprintf("%sa_field", prefix) { - t.Errorf("expected 'gotenberg_a_field' but got '%s'", fields[0].Key) - } - - if fields[1].Key != fmt.Sprintf("%sanother_field", prefix) { - t.Errorf("expected 'gotenberg_another_field' but got '%s'", fields[1].Key) - } - } - }) - } -} - -func Test_newLogLevel(t *testing.T) { - for _, tc := range []struct { - scenario string - level string - expectZapLevel zapcore.Level - expectError bool - }{ - { - scenario: "error level", - level: errorLoggingLevel, - expectZapLevel: zapcore.ErrorLevel, - }, - { - scenario: "warning level", - level: warnLoggingLevel, - expectZapLevel: zapcore.WarnLevel, - }, - { - scenario: "info level", - level: infoLoggingLevel, - expectZapLevel: zapcore.InfoLevel, - }, - { - scenario: "debug level", - level: debugLoggingLevel, - expectZapLevel: zapcore.DebugLevel, - }, - { - scenario: "invalid level", - level: "foo", - expectZapLevel: zapcore.InvalidLevel, - expectError: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - actual, err := newLogLevel(tc.level) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectZapLevel != actual { - t.Errorf("expected %d level but got %d", tc.expectZapLevel, actual) - } - }) - } -} - -func Test_newLogEncoder(t *testing.T) { - for _, tc := range []struct { - scenario string - format string - expectError bool - }{ - { - scenario: "auto format", - format: autoLoggingFormat, - }, - { - scenario: "text format", - format: textLoggingFormat, - }, - { - scenario: "json format", - format: jsonLoggingFormat, - }, - { - scenario: "invalid format", - format: "foo", - expectError: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - _, err := newLogEncoder(tc.format) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - - if !tc.expectError && err != nil { - t.Errorf("expected no error but got: %v", err) - } - }) - } -} diff --git a/pkg/modules/pdfcpu/doc.go b/pkg/modules/pdfcpu/doc.go index e68e2a61f..689a9a3d5 100644 --- a/pkg/modules/pdfcpu/doc.go +++ b/pkg/modules/pdfcpu/doc.go @@ -2,6 +2,8 @@ // interface using the pdfcpu command-line tool. This package allows for: // // 1. The merging of PDF files. +// 2. Import bookmarks in a PDF file. +// 3. The splitting of PDF files. // // See: https://github.com/pdfcpu/pdfcpu. package pdfcpu diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go index ac2d53589..3047d5cd9 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -5,6 +5,11 @@ import ( "errors" "fmt" "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "syscall" "go.uber.org/zap" @@ -51,6 +56,32 @@ func (engine *PdfCpu) Validate() error { 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. func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { 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) } +// 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(digitSuffixSort(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. 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) @@ -85,10 +171,92 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, met return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// EmbedFiles embeds files into a PDF. All files are embedded as file attachments +// without modifying the main PDF content. +func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { + if len(filePaths) == 0 { + return nil + } + + logger.Debug(fmt.Sprintf("embedding %d file(s) to %s: %v", len(filePaths), inputPath, filePaths)) + + args := []string{ + "attachments", "add", + inputPath, + } + args = append(args, filePaths...) + + cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) + if err != nil { + return fmt.Errorf("create command for attaching files: %w", err) + } + + _, err = cmd.Exec() + if err != nil { + return fmt.Errorf("attach files with pdfcpu: %w", err) + } + + return nil +} + +// Encrypt adds password protection to a PDF file using pdfcpu. +func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + if userPassword == "" { + return errors.New("user password cannot be empty") + } + + if ownerPassword == "" { + ownerPassword = userPassword + } + + var args []string + args = append(args, "encrypt") + args = append(args, "-mode", "aes") + args = append(args, "-upw", userPassword) + args = append(args, "-opw", ownerPassword) + args = append(args, "-perm", "all") + args = append(args, inputPath, inputPath) + + cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) + if err != nil { + return fmt.Errorf("create command: %w", err) + } + + _, err = cmd.Exec() + if err != nil { + return fmt.Errorf("encrypt PDF with pdfcpu: %w", err) + } + + return nil +} + +// ImportBookmarks imports bookmarks from a JSON file into a given PDF. +func (engine *PdfCpu) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error { + if inputBookmarksPath == "" { + return nil + } + + var args []string + args = append(args, "bookmarks", "import", inputPath, inputBookmarksPath, outputPath) + + 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("import bookmarks into PDFs with pdfcpu: %w", err) +} + // Interface guards. var ( _ gotenberg.Module = (*PdfCpu)(nil) _ gotenberg.Provisioner = (*PdfCpu)(nil) _ gotenberg.Validator = (*PdfCpu)(nil) + _ gotenberg.Debuggable = (*PdfCpu)(nil) _ gotenberg.PdfEngine = (*PdfCpu)(nil) ) diff --git a/pkg/modules/pdfcpu/pdfcpu_test.go b/pkg/modules/pdfcpu/pdfcpu_test.go deleted file mode 100644 index f009218a2..000000000 --- a/pkg/modules/pdfcpu/pdfcpu_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package pdfcpu - -import ( - "context" - "errors" - "os" - "reflect" - "testing" - - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestPdfCpu_Descriptor(t *testing.T) { - descriptor := new(PdfCpu).Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(PdfCpu)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestPdfCpu_Provision(t *testing.T) { - engine := new(PdfCpu) - ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil) - - err := engine.Provision(ctx) - if err != nil { - t.Errorf("expected no error but got: %v", err) - } -} - -func TestPdfCpu_Validate(t *testing.T) { - for _, tc := range []struct { - scenario string - binPath string - expectError bool - }{ - { - scenario: "empty bin path", - binPath: "", - expectError: true, - }, - { - scenario: "bin path does not exist", - binPath: "/foo", - expectError: true, - }, - { - scenario: "validate success", - binPath: os.Getenv("PDFTK_BIN_PATH"), - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - engine := new(PdfCpu) - engine.binPath = tc.binPath - err := engine.Validate() - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestPdfCpu_Merge(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx context.Context - inputPaths []string - expectError bool - }{ - { - scenario: "invalid context", - ctx: nil, - expectError: true, - }, - { - scenario: "invalid input path", - ctx: context.TODO(), - inputPaths: []string{ - "foo", - }, - expectError: true, - }, - { - scenario: "single file success", - ctx: context.TODO(), - inputPaths: []string{ - "/tests/test/testdata/pdfengines/sample1.pdf", - }, - expectError: false, - }, - { - scenario: "many files success", - ctx: context.TODO(), - inputPaths: []string{ - "/tests/test/testdata/pdfengines/sample1.pdf", - "/tests/test/testdata/pdfengines/sample2.pdf", - }, - expectError: false, - }, - } { - 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() - 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) - } - }() - - err = engine.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf") - - 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 TestPdfCpu_Convert(t *testing.T) { - mod := new(PdfCpu) - err := mod.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} - -func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) { - engine := new(PdfCpu) - _, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} - -func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) { - engine := new(PdfCpu) - err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} diff --git a/pkg/modules/pdfcpu/sort.go b/pkg/modules/pdfcpu/sort.go new file mode 100644 index 000000000..8ee83487d --- /dev/null +++ b/pkg/modules/pdfcpu/sort.go @@ -0,0 +1,68 @@ +package pdfcpu + +import ( + "path/filepath" + "regexp" + "sort" + "strconv" +) + +type digitSuffixSort []string + +func (s digitSuffixSort) Len() int { + return len(s) +} + +func (s digitSuffixSort) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +func (s digitSuffixSort) Less(i, j int) bool { + numI, restI := extractNumber(s[i]) + numJ, restJ := extractNumber(s[j]) + + // If both strings contain a number, compare them numerically. + if numI != -1 && numJ != -1 { + if numI != numJ { + return numI < numJ + } + // If the numbers are equal, compare the "rest" strings. + return restI < restJ + } + + // If one contains a number and the other doesn't, the one with the number + // comes first. + if numI != -1 { + return true + } + if numJ != -1 { + return false + } + + // Neither has a number; fall back to lexicographical order. + return s[i] < s[j] +} + +func extractNumber(str string) (int, string) { + str = filepath.Base(str) + + // Check for a number immediately before an extension. + if matches := extensionSuffixRegexp.FindStringSubmatch(str); len(matches) > 3 { + if num, err := strconv.Atoi(matches[2]); err == nil { + // Remove the numeric block but keep the extension. + return num, matches[1] + matches[3] + } + } + + // No numeric portion found. + return -1, str +} + +// Regular expressions used by extractNumber. +var ( + // Matches a numeric block immediately before a file extension. + extensionSuffixRegexp = regexp.MustCompile(`^(.*?)(\d+)(\.[^.]+)$`) +) + +// Interface guard. +var _ sort.Interface = (*digitSuffixSort)(nil) diff --git a/pkg/modules/pdfcpu/sort_test.go b/pkg/modules/pdfcpu/sort_test.go new file mode 100644 index 000000000..e7d94a789 --- /dev/null +++ b/pkg/modules/pdfcpu/sort_test.go @@ -0,0 +1,29 @@ +package pdfcpu + +import ( + "reflect" + "sort" + "testing" +) + +func TestDigitSuffixSort(t *testing.T) { + for _, tc := range []struct { + scenario string + values []string + expectedSort []string + }{ + { + scenario: "UUIDs with digit suffixes", + values: []string{"2521a33d-1fb4-4279-80fe-8a945285b8f4_12.pdf", "2521a33d-1fb4-4279-80fe-8a945285b8f4_1.pdf", "2521a33d-1fb4-4279-80fe-8a945285b8f4_10.pdf", "2521a33d-1fb4-4279-80fe-8a945285b8f4_3.pdf"}, + expectedSort: []string{"2521a33d-1fb4-4279-80fe-8a945285b8f4_1.pdf", "2521a33d-1fb4-4279-80fe-8a945285b8f4_3.pdf", "2521a33d-1fb4-4279-80fe-8a945285b8f4_10.pdf", "2521a33d-1fb4-4279-80fe-8a945285b8f4_12.pdf"}, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + sort.Sort(digitSuffixSort(tc.values)) + + if !reflect.DeepEqual(tc.values, tc.expectedSort) { + t.Fatalf("expected %+v but got: %+v", tc.expectedSort, tc.values) + } + }) + } +} diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go index 4cbbc3eac..935b1d7c2 100644 --- a/pkg/modules/pdfengines/multi.go +++ b/pkg/modules/pdfengines/multi.go @@ -12,28 +12,43 @@ import ( ) type multiPdfEngines struct { - mergeEngines []gotenberg.PdfEngine - convertEngines []gotenberg.PdfEngine - readMedataEngines []gotenberg.PdfEngine - writeMedataEngines []gotenberg.PdfEngine + mergeEngines []gotenberg.PdfEngine + splitEngines []gotenberg.PdfEngine + flattenEngines []gotenberg.PdfEngine + convertEngines []gotenberg.PdfEngine + readMetadataEngines []gotenberg.PdfEngine + writeMetadataEngines []gotenberg.PdfEngine + passwordEngines []gotenberg.PdfEngine + embedEngines []gotenberg.PdfEngine + importBookmarksEngines []gotenberg.PdfEngine } func newMultiPdfEngines( mergeEngines, + splitEngines, + flattenEngines, convertEngines, readMetadataEngines, - writeMedataEngines []gotenberg.PdfEngine, + writeMetadataEngines, + passwordEngines, + embedEngines, + importBookmarksEngines []gotenberg.PdfEngine, ) *multiPdfEngines { return &multiPdfEngines{ - mergeEngines: mergeEngines, - convertEngines: convertEngines, - readMedataEngines: readMetadataEngines, - writeMedataEngines: writeMedataEngines, + mergeEngines: mergeEngines, + splitEngines: splitEngines, + flattenEngines: flattenEngines, + convertEngines: convertEngines, + readMetadataEngines: readMetadataEngines, + writeMetadataEngines: writeMetadataEngines, + passwordEngines: passwordEngines, + embedEngines: embedEngines, + importBookmarksEngines: importBookmarksEngines, } } -// Merge tries to merge the given PDFs into a unique PDF thanks to its -// children. If the context is done, it stops and returns an error. +// Merge combines multiple PDF files into a single document using the first +// available engine that supports PDF merging. func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { var err error errChan := make(chan error, 1) @@ -57,8 +72,69 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp return fmt.Errorf("merge PDFs with multi PDF engines: %w", err) } -// 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. +type splitResult struct { + outputPaths []string + err error +} + +// Split divides the PDF into separate pages using the first available engine +// that supports PDF splitting. +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 using the +// first available engine that supports flattening. +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 transforms the given PDF to a specific PDF format using the first +// available engine that supports PDF conversion. func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { var err error errChan := make(chan error, 1) @@ -87,20 +163,20 @@ type readMetadataResult struct { err error } +// ReadMetadata extracts metadata from a PDF file using the first available +// engine that supports metadata reading. func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { var err error 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) { metadata, err := engine.ReadMetadata(ctx, logger, inputPath) resultChan <- readMetadataResult{metadata: metadata, err: err} }(engine) - } - for range multi.readMedataEngines { select { case result := <-resultChan: if result.err != nil { @@ -118,11 +194,13 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg return nil, fmt.Errorf("read PDF metadata with multi PDF engines: %w", err) } +// WriteMetadata embeds metadata into a PDF file using the first available +// engine that supports metadata writing. func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { var err error errChan := make(chan error, 1) - for _, engine := range multi.writeMedataEngines { + for _, engine := range multi.writeMetadataEngines { go func(engine gotenberg.PdfEngine) { errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath) }(engine) @@ -141,6 +219,81 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err) } +// Encrypt adds password protection to a PDF file using the first available +// engine that supports password protection. +func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + var err error + errChan := make(chan error, 1) + + for _, engine := range multi.passwordEngines { + go func(engine gotenberg.PdfEngine) { + errChan <- engine.Encrypt(ctx, logger, inputPath, userPassword, ownerPassword) + }(engine) + + select { + case protectErr := <-errChan: + errored := multierr.AppendInto(&err, protectErr) + if !errored { + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + } + + return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) +} + +// EmbedFiles embeds files into a PDF using the first available +// engine that supports file embedding. +func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { + var err error + errChan := make(chan error, 1) + + for _, engine := range multi.embedEngines { + go func(engine gotenberg.PdfEngine) { + errChan <- engine.EmbedFiles(ctx, logger, filePaths, inputPath) + }(engine) + + select { + case embedErr := <-errChan: + errored := multierr.AppendInto(&err, embedErr) + if !errored { + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + } + + return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err) +} + +// ImportBookmarks imports bookmarks from a JSON file into a PDF using the first available +// engine that supports bookmark importing. +func (multi *multiPdfEngines) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error { + var err error + errChan := make(chan error, 1) + + for _, engine := range multi.importBookmarksEngines { + go func(engine gotenberg.PdfEngine) { + errChan <- engine.ImportBookmarks(ctx, logger, inputPath, inputBookmarksPath, outputPath) + }(engine) + + select { + case mergeErr := <-errChan: + errored := multierr.AppendInto(&err, mergeErr) + if !errored { + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + } + + return fmt.Errorf("import bookmarks into PDF with multi PDF engines: %w", err) +} + // Interface guards. var ( _ gotenberg.PdfEngine = (*multiPdfEngines)(nil) diff --git a/pkg/modules/pdfengines/multi_test.go b/pkg/modules/pdfengines/multi_test.go index 00e706d78..496a4403d 100644 --- a/pkg/modules/pdfengines/multi_test.go +++ b/pkg/modules/pdfengines/multi_test.go @@ -19,25 +19,22 @@ func TestMultiPdfEngines_Merge(t *testing.T) { }{ { scenario: "nominal behavior", - engine: newMultiPdfEngines( - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + mergeEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return nil }, }, }, - nil, - nil, - nil, - ), + }, ctx: context.Background(), expectError: false, }, { scenario: "at least one engine does not return an error", - engine: newMultiPdfEngines( - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + mergeEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return errors.New("foo") @@ -49,17 +46,14 @@ func TestMultiPdfEngines_Merge(t *testing.T) { }, }, }, - nil, - nil, - nil, - ), + }, ctx: context.Background(), expectError: false, }, { scenario: "all engines return an error", - engine: newMultiPdfEngines( - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + mergeEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return errors.New("foo") @@ -71,27 +65,21 @@ func TestMultiPdfEngines_Merge(t *testing.T) { }, }, }, - nil, - nil, - nil, - ), + }, ctx: context.Background(), expectError: true, }, { scenario: "context expired", - engine: newMultiPdfEngines( - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + mergeEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return nil }, }, }, - nil, - nil, - nil, - ), + }, ctx: func() context.Context { ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -115,6 +103,279 @@ func TestMultiPdfEngines_Merge(t *testing.T) { } } +func TestMultiPdfEngines_Encrypt(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *multiPdfEngines + ctx context.Context + expectError bool + }{ + { + scenario: "nominal behavior", + engine: &multiPdfEngines{ + passwordEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "at least one engine does not return an error", + engine: &multiPdfEngines{ + passwordEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "all engines return an error", + engine: &multiPdfEngines{ + passwordEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return errors.New("foo") + }, + }, + }, + }, + ctx: context.Background(), + expectError: true, + }, + { + scenario: "context expired", + engine: &multiPdfEngines{ + passwordEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword 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.Encrypt(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_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) { for _, tc := range []struct { scenario string @@ -124,25 +385,21 @@ func TestMultiPdfEngines_Convert(t *testing.T) { }{ { scenario: "nominal behavior", - engine: newMultiPdfEngines( - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + convertEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return nil }, }, }, - nil, - nil, - ), + }, ctx: context.Background(), }, { scenario: "at least one engine does not return an error", - engine: newMultiPdfEngines( - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + convertEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return errors.New("foo") @@ -154,16 +411,13 @@ func TestMultiPdfEngines_Convert(t *testing.T) { }, }, }, - nil, - nil, - ), + }, ctx: context.Background(), }, { scenario: "all engines return an error", - engine: newMultiPdfEngines( - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + convertEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return errors.New("foo") @@ -175,26 +429,21 @@ func TestMultiPdfEngines_Convert(t *testing.T) { }, }, }, - nil, - nil, - ), + }, ctx: context.Background(), expectError: true, }, { scenario: "context expired", - engine: newMultiPdfEngines( - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + convertEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return nil }, }, }, - nil, - nil, - ), + }, ctx: func() context.Context { ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -227,26 +476,21 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) { }{ { scenario: "nominal behavior", - engine: newMultiPdfEngines( - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + readMetadataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { return make(map[string]interface{}), nil }, }, }, - nil, - ), + }, ctx: context.Background(), }, { scenario: "at least one engine does not return an error", - engine: newMultiPdfEngines( - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + readMetadataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { return nil, errors.New("foo") @@ -258,16 +502,13 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) { }, }, }, - nil, - ), + }, ctx: context.Background(), }, { scenario: "all engines return an error", - engine: newMultiPdfEngines( - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + readMetadataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { return nil, errors.New("foo") @@ -279,25 +520,21 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) { }, }, }, - nil, - ), + }, ctx: context.Background(), expectError: true, }, { scenario: "context expired", - engine: newMultiPdfEngines( - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + readMetadataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { return make(map[string]interface{}), nil }, }, }, - nil, - ), + }, ctx: func() context.Context { ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -330,27 +567,21 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) { }{ { scenario: "nominal behavior", - engine: newMultiPdfEngines( - nil, - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + writeMetadataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { return nil }, }, }, - ), + }, ctx: context.Background(), }, { scenario: "at least one engine does not return an error", - engine: newMultiPdfEngines( - nil, - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + writeMetadataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { return errors.New("foo") @@ -362,16 +593,13 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) { }, }, }, - ), + }, ctx: context.Background(), }, { scenario: "all engines return an error", - engine: newMultiPdfEngines( - nil, - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + writeMetadataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { return errors.New("foo") @@ -383,24 +611,21 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) { }, }, }, - ), + }, ctx: context.Background(), expectError: true, }, { scenario: "context expired", - engine: newMultiPdfEngines( - nil, - nil, - nil, - []gotenberg.PdfEngine{ + engine: &multiPdfEngines{ + writeMetadataEngines: []gotenberg.PdfEngine{ &gotenberg.PdfEngineMock{ WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { return nil }, }, }, - ), + }, ctx: func() context.Context { ctx, cancel := context.WithCancel(context.Background()) cancel() diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index 7bd000187..dbbd1c1c8 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -27,12 +27,17 @@ func init() { // the [api.Router] interface to expose relevant PDF processing routes if // enabled. type PdfEngines struct { - mergeNames []string - convertNames []string - readMetadataNames []string - writeMedataNames []string - engines []gotenberg.PdfEngine - disableRoutes bool + mergeNames []string + splitNames []string + flattenNames []string + convertNames []string + readMetadataNames []string + writeMetadataNames []string + encryptNames []string + embedNames []string + importBookmarksNames []string + engines []gotenberg.PdfEngine + disableRoutes bool } // Descriptor returns a PdfEngines' module descriptor. @@ -42,11 +47,17 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor { FlagSet: func() *flag.FlagSet { 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-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-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-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all") + fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all") + fs.StringSlice("pdfengines-import-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the import bookmarks feature - empty means all") fs.Bool("pdfengines-disable-routes", false, "Disable the routes") + // Deprecated flags. fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the default PDF engines and their default order - all by default") err := fs.MarkDeprecated("pdfengines-engines", "use other flags for a more granular selection of PDF engines per method") if err != nil { @@ -64,9 +75,14 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor { func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { flags := ctx.ParsedFlags() mergeNames := flags.MustStringSlice("pdfengines-merge-engines") + splitNames := flags.MustStringSlice("pdfengines-split-engines") + flattenNames := flags.MustStringSlice("pdfengines-flatten-engines") convertNames := flags.MustStringSlice("pdfengines-convert-engines") readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines") writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines") + encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines") + embedNames := flags.MustStringSlice("pdfengines-embed-engines") + importBookmarksNames := flags.MustStringSlice("pdfengines-import-bookmarks-engines") mod.disableRoutes = flags.MustBool("pdfengines-disable-routes") engines, err := ctx.Modules(new(gotenberg.PdfEngine)) @@ -85,7 +101,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { defaultNames[i] = engine.(gotenberg.Module).Descriptor().ID } - // Example in case of deprecated module name. + // Example in the case of deprecated module name. //for i, name := range defaultNames { // if name == "unoconv-pdfengine" || name == "uno-pdfengine" { // logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name)) @@ -98,6 +114,16 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { 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 if len(convertNames) > 0 { mod.convertNames = convertNames @@ -108,9 +134,24 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { mod.readMetadataNames = readMetadataNames } - mod.writeMedataNames = defaultNames + mod.writeMetadataNames = defaultNames if len(writeMetadataNames) > 0 { - mod.writeMedataNames = writeMetadataNames + mod.writeMetadataNames = writeMetadataNames + } + + mod.encryptNames = defaultNames + if len(encryptNames) > 0 { + mod.encryptNames = encryptNames + } + + mod.embedNames = defaultNames + if len(embedNames) > 0 { + mod.embedNames = embedNames + } + + mod.importBookmarksNames = defaultNames + if len(importBookmarksNames) > 0 { + mod.importBookmarksNames = importBookmarksNames } return nil @@ -161,9 +202,14 @@ func (mod *PdfEngines) Validate() error { } findNonExistingEngines(mod.mergeNames) + findNonExistingEngines(mod.splitNames) + findNonExistingEngines(mod.flattenNames) findNonExistingEngines(mod.convertNames) findNonExistingEngines(mod.readMetadataNames) - findNonExistingEngines(mod.writeMedataNames) + findNonExistingEngines(mod.writeMetadataNames) + findNonExistingEngines(mod.encryptNames) + findNonExistingEngines(mod.embedNames) + findNonExistingEngines(mod.importBookmarksNames) if len(nonExistingEngines) == 0 { return nil @@ -177,9 +223,13 @@ func (mod *PdfEngines) Validate() error { func (mod *PdfEngines) SystemMessages() []string { return []string{ 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("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[:], " ")), + fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames[:], " ")), + fmt.Sprintf("import bookmarks engines - %s", strings.Join(mod.importBookmarksNames[:], " ")), } } @@ -201,9 +251,14 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) { return newMultiPdfEngines( engines(mod.mergeNames), + engines(mod.splitNames), + engines(mod.flattenNames), engines(mod.convertNames), engines(mod.readMetadataNames), - engines(mod.writeMedataNames), + engines(mod.writeMetadataNames), + engines(mod.encryptNames), + engines(mod.embedNames), + engines(mod.importBookmarksNames), ), nil } @@ -222,9 +277,13 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) { return []api.Route{ mergeRoute(engine), + splitRoute(engine), + flattenRoute(engine), convertRoute(engine), readMetadataRoute(engine), writeMetadataRoute(engine), + encryptRoute(engine), + embedRoute(engine), }, nil } diff --git a/pkg/modules/pdfengines/pdfengines_test.go b/pkg/modules/pdfengines/pdfengines_test.go deleted file mode 100644 index fe999432d..000000000 --- a/pkg/modules/pdfengines/pdfengines_test.go +++ /dev/null @@ -1,396 +0,0 @@ -package pdfengines - -import ( - "errors" - "fmt" - "reflect" - "strings" - "testing" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestPdfEngines_Descriptor(t *testing.T) { - descriptor := new(PdfEngines).Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(PdfEngines)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestPdfEngines_Provision(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *gotenberg.Context - expectedMergePdfEngines []string - expectedConvertPdfEngines []string - expectedReadMetadataPdfEngines []string - expectedWriteMetadataPdfEngines []string - expectError bool - }{ - { - scenario: "no selection from user", - ctx: func() *gotenberg.Context { - provider := &struct { - gotenberg.ModuleMock - }{} - provider.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { - return provider - }} - } - - engine := &struct { - gotenberg.ModuleMock - gotenberg.ValidatorMock - gotenberg.PdfEngineMock - }{} - engine.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine }} - } - engine.ValidateMock = func() error { - return nil - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(PdfEngines).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - provider.Descriptor(), - engine.Descriptor(), - }, - ) - }(), - expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"}, - expectedConvertPdfEngines: []string{"libreoffice-pdfengine"}, - expectedReadMetadataPdfEngines: []string{"exiftool"}, - expectedWriteMetadataPdfEngines: []string{"exiftool"}, - expectError: false, - }, - { - scenario: "selection from user", - ctx: func() *gotenberg.Context { - provider := &struct { - gotenberg.ModuleMock - }{} - provider.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { - return provider - }} - } - engine1 := &struct { - gotenberg.ModuleMock - gotenberg.ValidatorMock - gotenberg.PdfEngineMock - }{} - engine1.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "a", New: func() gotenberg.Module { return engine1 }} - } - engine1.ValidateMock = func() error { - return nil - } - - engine2 := &struct { - gotenberg.ModuleMock - gotenberg.ValidatorMock - gotenberg.PdfEngineMock - }{} - engine2.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "b", New: func() gotenberg.Module { return engine2 }} - } - engine2.ValidateMock = func() error { - return nil - } - - 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"}) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: fs, - }, - []gotenberg.ModuleDescriptor{ - provider.Descriptor(), - engine1.Descriptor(), - engine2.Descriptor(), - }, - ) - }(), - - expectedMergePdfEngines: []string{"b"}, - expectedConvertPdfEngines: []string{"b"}, - expectedReadMetadataPdfEngines: []string{"a"}, - expectedWriteMetadataPdfEngines: []string{"a"}, - expectError: false, - }, - { - scenario: "no valid PDF engine", - ctx: func() *gotenberg.Context { - provider := &struct { - gotenberg.ModuleMock - }{} - provider.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { - return provider - }} - } - engine := &struct { - gotenberg.ModuleMock - gotenberg.ValidatorMock - gotenberg.PdfEngineMock - }{} - engine.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine }} - } - engine.ValidateMock = func() error { - return errors.New("foo") - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(PdfEngines).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - provider.Descriptor(), - engine.Descriptor(), - }, - ) - }(), - expectError: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(PdfEngines) - err := mod.Provision(tc.ctx) - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - - if len(tc.expectedMergePdfEngines) != len(mod.mergeNames) { - t.Fatalf("expected %d merge names but got %d", len(tc.expectedMergePdfEngines), len(mod.mergeNames)) - } - - if len(tc.expectedConvertPdfEngines) != len(mod.convertNames) { - t.Fatalf("expected %d convert names but got %d", len(tc.expectedConvertPdfEngines), len(mod.convertNames)) - } - - if 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) { - t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMedataNames)) - } - - for index, name := range mod.mergeNames { - if name != tc.expectedMergePdfEngines[index] { - t.Fatalf("expected merge name at index %d to be %s, but got: %s", index, name, tc.expectedMergePdfEngines[index]) - } - } - - for index, name := range mod.convertNames { - if name != tc.expectedConvertPdfEngines[index] { - t.Fatalf("expected convert name at index %d to be %s, but got: %s", index, name, tc.expectedConvertPdfEngines[index]) - } - } - - for index, name := range mod.readMetadataNames { - if name != tc.expectedReadMetadataPdfEngines[index] { - t.Fatalf("expected read metadata name at index %d to be %s, but got: %s", index, name, tc.expectedReadMetadataPdfEngines[index]) - } - } - - for index, name := range mod.writeMedataNames { - 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]) - } - } - }) - } -} - -func TestPdfEngines_Validate(t *testing.T) { - for _, tc := range []struct { - scenario string - names []string - engines []gotenberg.PdfEngine - expectError bool - }{ - { - scenario: "existing PDF engine", - names: []string{"foo"}, - engines: func() []gotenberg.PdfEngine { - engine := &struct { - gotenberg.ModuleMock - gotenberg.PdfEngineMock - }{} - engine.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine }} - } - - return []gotenberg.PdfEngine{ - engine, - } - }(), - expectError: false, - }, - { - scenario: "non-existing bar PDF engine", - names: []string{"foo", "bar", "baz"}, - engines: func() []gotenberg.PdfEngine { - engine1 := &struct { - gotenberg.ModuleMock - gotenberg.PdfEngineMock - }{} - engine1.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine1 }} - } - - engine2 := &struct { - gotenberg.ModuleMock - gotenberg.PdfEngineMock - }{} - engine2.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return engine2 }} - } - - return []gotenberg.PdfEngine{ - engine1, - engine2, - } - }(), - expectError: true, - }, - { - scenario: "no PDF engine", - expectError: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := PdfEngines{ - mergeNames: tc.names, - convertNames: tc.names, - readMetadataNames: tc.names, - writeMedataNames: tc.names, - engines: tc.engines, - } - - err := mod.Validate() - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestPdfEngines_SystemMessages(t *testing.T) { - mod := new(PdfEngines) - mod.mergeNames = []string{"foo", "bar"} - mod.convertNames = []string{"foo", "bar"} - mod.readMetadataNames = []string{"foo", "bar"} - mod.writeMedataNames = []string{"foo", "bar"} - - messages := mod.SystemMessages() - if len(messages) != 4 { - t.Errorf("expected one and only one message, but got %d", len(messages)) - } - - expect := []string{ - fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")), - fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")), - fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")), - fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")), - } - - for i, message := range messages { - if message != expect[i] { - t.Errorf("expected message at index %d to be %s, but got %s", i, message, expect[i]) - } - } -} - -func TestPdfEngines_PdfEngine(t *testing.T) { - mod := PdfEngines{ - mergeNames: []string{"foo", "bar"}, - convertNames: []string{"foo", "bar"}, - readMetadataNames: []string{"foo", "bar"}, - writeMedataNames: []string{"foo", "bar"}, - engines: func() []gotenberg.PdfEngine { - engine1 := &struct { - gotenberg.ModuleMock - gotenberg.PdfEngineMock - }{} - engine1.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine1 }} - } - - engine2 := &struct { - gotenberg.ModuleMock - gotenberg.PdfEngineMock - }{} - engine2.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine2 }} - } - - return []gotenberg.PdfEngine{ - engine1, - engine2, - } - }(), - } - - _, err := mod.PdfEngine() - if err != nil { - t.Errorf("expected no error but got: %v", err) - } -} - -func TestPdfEngines_Routes(t *testing.T) { - for _, tc := range []struct { - scenario string - expectRoutes int - disableRoutes bool - }{ - { - scenario: "routes not disabled", - expectRoutes: 4, - disableRoutes: false, - }, - { - scenario: "routes disabled", - expectRoutes: 0, - disableRoutes: true, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(PdfEngines) - mod.disableRoutes = tc.disableRoutes - - routes, err := mod.Routes() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectRoutes != len(routes) { - t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes)) - } - }) - } -} diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index a0ddb756e..576d2b7e4 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -5,7 +5,10 @@ import ( "errors" "fmt" "net/http" + "os" "path/filepath" + "strconv" + "strings" "github.com/labstack/echo/v4" @@ -13,6 +16,74 @@ import ( "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 { @@ -32,9 +103,10 @@ func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats { } // FormDataPdfMetadata creates metadata object from the form data. -func FormDataPdfMetadata(form *api.FormData) map[string]interface{} { +func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]interface{} { var metadata map[string]interface{} - form.Custom("metadata", func(value string) error { + + metadataFunc := func(value string) error { if len(value) > 0 { err := json.Unmarshal([]byte(value), &metadata) if err != nil { @@ -42,7 +114,18 @@ func FormDataPdfMetadata(form *api.FormData) map[string]interface{} { } } 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 } @@ -66,6 +149,73 @@ func MergeStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string 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. @@ -105,6 +255,73 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma return nil } +// FormDataPdfEmbeds extracts embedded file paths from form data. +// Only files uploaded with the "embeds" field name are included. +func FormDataPdfEmbeds(form *api.FormData) []string { + var embedPaths []string + form.Embeds(&embedPaths) + return embedPaths +} + +// FormDataPdfEncrypt extracts encryption parameters from form data. +func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) { + form.String("userPassword", &userPassword, "") + form.String("ownerPassword", &ownerPassword, "") + return userPassword, ownerPassword +} + +// EncryptPdfStub adds password protection to PDF files. +func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, ownerPassword string, inputPaths []string) error { + if userPassword == "" { + return nil + } + + for _, inputPath := range inputPaths { + err := engine.Encrypt(ctx, ctx.Log(), inputPath, userPassword, ownerPassword) + if err != nil { + return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err) + } + } + + return nil +} + +// EmbedFilesStub embeds files into PDF files. +func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []string, inputPaths []string) error { + if len(embedPaths) == 0 { + return nil + } + + for _, inputPath := range inputPaths { + err := engine.EmbedFiles(ctx, ctx.Log(), embedPaths, inputPath) + if err != nil { + return fmt.Errorf("embed files into PDF '%s': %w", inputPath, err) + } + } + + return nil +} + +// ImportBookmarksStub imports bookmarks into a PDF file. +func ImportBookmarksStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPath string, inputBookmarks []byte, outputPath string) (string, error) { + if len(inputBookmarks) == 0 { + fmt.Println("ImportBookmarksStub BM empty") + return inputPath, nil + } + + inputBookmarksPath := ctx.GeneratePath(".json") + err := os.WriteFile(inputBookmarksPath, inputBookmarks, 0o600) + if err != nil { + return "", fmt.Errorf("write file %v: %w", inputBookmarksPath, err) + } + err = engine.ImportBookmarks(ctx, ctx.Log(), inputPath, inputBookmarksPath, outputPath) + if err != nil { + return "", fmt.Errorf("import bookmarks %v: %w", inputPath, err) + } + + return outputPath, nil +} + // mergeRoute returns an [api.Route] which can merge PDFs. func mergeRoute(engine gotenberg.PdfEngine) api.Route { return api.Route{ @@ -116,11 +333,15 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { form := ctx.FormData() pdfFormats := FormDataPdfFormats(form) - metadata := FormDataPdfMetadata(form) + metadata := FormDataPdfMetadata(form, false) + userPassword, ownerPassword := FormDataPdfEncrypt(form) + embedPaths := FormDataPdfEmbeds(form) 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) @@ -137,11 +358,28 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("convert PDF: %w", err) } + err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths) + if err != nil { + return fmt.Errorf("embed files into PDFs: %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 = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) + if err != nil { + return fmt.Errorf("encrypt PDFs: %w", err) + } + err = ctx.AddOutputPaths(outputPaths...) if err != nil { return fmt.Errorf("add output paths: %w", err) @@ -152,6 +390,120 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { } } +// 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) + userPassword, ownerPassword := FormDataPdfEncrypt(form) + embedPaths := FormDataPdfEmbeds(form) + + 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 = EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths) + if err != nil { + return fmt.Errorf("embed files into 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) + } + } + + err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) + if err != nil { + return fmt.Errorf("encrypt 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 + }, + } +} + // convertRoute returns an [api.Route] which can convert PDFs to a specific ODF // format. func convertRoute(engine gotenberg.PdfEngine) api.Route { @@ -196,7 +548,6 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route { if err != nil { return fmt.Errorf("rename output path: %w", err) } - outputPaths[i] = inputPath } } @@ -240,6 +591,11 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route { err = c.JSON(http.StatusOK, res) if err != nil { + if strings.Contains(err.Error(), "request method or response status code does not allow body") { + // High probability that the user is using the webhook + // feature. It does not make sense for this route. + return api.ErrNoOutputFile + } return fmt.Errorf("return JSON response: %w", err) } @@ -258,25 +614,12 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route { Handler: func(c echo.Context) error { ctx := c.Get("context").(*api.Context) - var ( - inputPaths []string - metadata map[string]interface{} - ) + form := ctx.FormData() + metadata := FormDataPdfMetadata(form, true) - err := ctx.FormData(). + var inputPaths []string + err := form. 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() if err != nil { return fmt.Errorf("validate form data: %w", err) @@ -296,3 +639,76 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route { }, } } + +// encryptRoute returns an [api.Route] which can add password protection to PDFs. +func encryptRoute(engine gotenberg.PdfEngine) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/pdfengines/encrypt", + IsMultipart: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*api.Context) + + form := ctx.FormData() + + var inputPaths []string + var userPassword string + var ownerPassword string + err := form. + MandatoryPaths([]string{".pdf"}, &inputPaths). + MandatoryString("userPassword", &userPassword). + String("ownerPassword", &ownerPassword, ""). + Validate() + if err != nil { + return fmt.Errorf("validate form data: %w", err) + } + + err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, inputPaths) + if err != nil { + return fmt.Errorf("encrypt PDFs: %w", err) + } + + err = ctx.AddOutputPaths(inputPaths...) + if err != nil { + return fmt.Errorf("add output paths: %w", err) + } + + return nil + }, + } +} + +// embedRoute returns an [api.Route] which can add embedded files to PDFs. +func embedRoute(engine gotenberg.PdfEngine) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/pdfengines/embed", + IsMultipart: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*api.Context) + + form := ctx.FormData() + embedPaths := FormDataPdfEmbeds(form) + + var inputPaths []string + err := form. + MandatoryPaths([]string{".pdf"}, &inputPaths). + Validate() + if err != nil { + return fmt.Errorf("validate form data: %w", err) + } + + err = EmbedFilesStub(ctx, engine, embedPaths, inputPaths) + if err != nil { + return fmt.Errorf("embed files into PDFs: %w", err) + } + + err = ctx.AddOutputPaths(inputPaths...) + if err != nil { + return fmt.Errorf("add output paths: %w", err) + } + + return nil + }, + } +} diff --git a/pkg/modules/pdfengines/routes_test.go b/pkg/modules/pdfengines/routes_test.go deleted file mode 100644 index 94df1688d..000000000 --- a/pkg/modules/pdfengines/routes_test.go +++ /dev/null @@ -1,1010 +0,0 @@ -package pdfengines - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "reflect" - "slices" - "strings" - "testing" - - "github.com/labstack/echo/v4" - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" - "github.com/gotenberg/gotenberg/v8/pkg/modules/api" -) - -func TestFormDataPdfFormats(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 := FormDataPdfFormats(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 TestMergeStub(t *testing.T) { - for _, tc := range []struct { - scenario string - engine gotenberg.PdfEngine - inputPaths []string - expectError bool - }{ - { - scenario: "no input path (nil)", - inputPaths: nil, - expectError: true, - }, - { - scenario: "no input path (empty)", - inputPaths: make([]string, 0), - expectError: true, - }, - { - scenario: "only one input path", - inputPaths: []string{"my.pdf"}, - expectError: false, - }, - { - scenario: "merge error", - engine: &gotenberg.PdfEngineMock{ - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return errors.New("foo") - }, - }, - inputPaths: []string{"my.pdf", "my2.pdf"}, - expectError: true, - }, - { - scenario: "merge success", - engine: &gotenberg.PdfEngineMock{ - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return nil - }, - }, - inputPaths: []string{"my.pdf", "my2.pdf"}, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - _, err := MergeStub(new(api.Context), tc.engine, tc.inputPaths) - - 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 TestConvertStub(t *testing.T) { - for _, tc := range []struct { - scenario string - engine gotenberg.PdfEngine - pdfFormats gotenberg.PdfFormats - expectError bool - }{ - { - scenario: "no PDF formats", - pdfFormats: gotenberg.PdfFormats{}, - expectError: false, - }, - { - scenario: "convert error", - engine: &gotenberg.PdfEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return errors.New("foo") - }, - }, - pdfFormats: gotenberg.PdfFormats{ - PdfA: gotenberg.PdfA3b, - PdfUa: true, - }, - expectError: true, - }, - { - scenario: "convert success", - engine: &gotenberg.PdfEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return nil - }, - }, - pdfFormats: gotenberg.PdfFormats{ - PdfA: gotenberg.PdfA3b, - PdfUa: true, - }, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - _, err := ConvertStub(new(api.Context), tc.engine, tc.pdfFormats, []string{"my.pdf", "my2.pdf"}) - - 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 TestWriteMetadataStub(t *testing.T) { - for _, tc := range []struct { - scenario string - engine gotenberg.PdfEngine - metadata map[string]interface{} - expectError bool - }{ - { - scenario: "no metadata (nil)", - metadata: nil, - expectError: false, - }, - { - scenario: "no metadata (empty)", - metadata: make(map[string]interface{}, 0), - expectError: false, - }, - { - scenario: "write metadata error", - engine: &gotenberg.PdfEngineMock{ - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { - return errors.New("foo") - }, - }, - metadata: map[string]interface{}{"foo": "bar"}, - expectError: true, - }, - { - scenario: "write metadata success", - engine: &gotenberg.PdfEngineMock{ - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { - return nil - }, - }, - metadata: map[string]interface{}{"foo": "bar"}, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - err := WriteMetadataStub(new(api.Context), tc.engine, tc.metadata, []string{"my.pdf", "my2.pdf"}) - - 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 TestMergeHandler(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - engine gotenberg.PdfEngine - expectError bool - expectHttpError bool - expectHttpStatus int - expectOutputPathsCount int - }{ - { - scenario: "missing at least one mandatory file", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "invalid metadata form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - "file2.pdf": "/file2.pdf", - }) - ctx.SetValues(map[string][]string{ - "metadata": { - "foo", - }, - }) - return ctx - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "PDF engine merge error", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - "file2.pdf": "/file2.pdf", - }) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return errors.New("foo") - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "PDF engine convert error", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - "file2.pdf": "/file2.pdf", - }) - ctx.SetValues(map[string][]string{ - "pdfa": { - gotenberg.PdfA1b, - }, - }) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return nil - }, - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return errors.New("foo") - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "PDF engine write metadata error", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - "file2.pdf": "/file2.pdf", - }) - ctx.SetValues(map[string][]string{ - "metadata": { - "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", - }, - }) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return nil - }, - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { - return errors.New("foo") - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "cannot add output paths", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - "file2.pdf": "/file2.pdf", - }) - ctx.SetCancelled(true) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return nil - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - "file2.pdf": "/file2.pdf", - }) - ctx.SetValues(map[string][]string{ - "pdfa": { - gotenberg.PdfA1b, - }, - "metadata": { - "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", - }, - }) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return nil - }, - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return nil - }, - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { - return nil - }, - }, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - c := echo.New().NewContext(nil, nil) - c.Set("context", tc.ctx.Context) - - err := mergeRoute(tc.engine).Handler(c) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } - }) - } -} - -func TestConvertHandler(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - engine gotenberg.PdfEngine - expectError bool - expectHttpError bool - expectHttpStatus int - expectOutputPathsCount int - expectOutputPaths []string - }{ - { - scenario: "missing at least one mandatory file", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "no PDF formats", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - }) - return ctx - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "error from PDF engine", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - }) - ctx.SetValues(map[string][]string{ - "pdfa": { - gotenberg.PdfA1b, - }, - }) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return errors.New("foo") - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "cannot add output paths", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - }) - ctx.SetValues(map[string][]string{ - "pdfa": { - gotenberg.PdfA1b, - }, - }) - ctx.SetCancelled(true) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return nil - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success with PDF/A & PDF/UA form fields (single file)", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - }) - ctx.SetValues(map[string][]string{ - "pdfa": { - gotenberg.PdfA1b, - }, - "pdfua": { - "true", - }, - }) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return nil - }, - }, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - { - scenario: "cannot rename many files", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - "file2.pdf": "/file2.pdf", - }) - ctx.SetValues(map[string][]string{ - "pdfa": { - gotenberg.PdfA1b, - }, - "pdfua": { - "true", - }, - }) - ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { - return errors.New("cannot rename") - }}) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return nil - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success with PDF/A & PDF/UA form fields (many files)", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - "file2.pdf": "/file2.pdf", - }) - ctx.SetValues(map[string][]string{ - "pdfa": { - gotenberg.PdfA1b, - }, - "pdfua": { - "true", - }, - }) - ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { - return nil - }}) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return nil - }, - }, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 2, - expectOutputPaths: []string{"/file.pdf", "/file2.pdf"}, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - c := echo.New().NewContext(nil, nil) - c.Set("context", tc.ctx.Context) - - err := convertRoute(tc.engine).Handler(c) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } - - for _, path := range tc.expectOutputPaths { - if !slices.Contains(tc.ctx.OutputPaths(), path) { - t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths()) - } - } - }) - } -} - -func TestReadMetadataHandler(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - engine gotenberg.PdfEngine - expectError bool - expectedError error - expectHttpError bool - expectHttpStatus int - expectedJson string - }{ - { - scenario: "missing at least one mandatory file", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "error from PDF engine", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - }) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { - return nil, errors.New("foo") - }, - }, - expectError: true, - expectHttpError: false, - }, - { - scenario: "success", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - }) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { - return map[string]interface{}{ - "foo": "bar", - "bar": "foo", - }, nil - }, - }, - expectError: true, - expectedError: api.ErrNoOutputFile, - expectHttpError: false, - expectedJson: `{"file.pdf":{"bar":"foo","foo":"bar"}}`, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - req := httptest.NewRequest(http.MethodPost, "/forms/pdfengines/metadata/read", nil) - rec := httptest.NewRecorder() - c := echo.New().NewContext(req, rec) - c.Set("context", tc.ctx.Context) - - err := readMetadataRoute(tc.engine).Handler(c) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { - t.Fatalf("expected error %v but got: %v", tc.expectedError, err) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - - if tc.expectedJson != "" && tc.expectedJson != strings.TrimSpace(rec.Body.String()) { - t.Errorf("expected '%s' as HTTP response but got '%s'", tc.expectedJson, strings.TrimSpace(rec.Body.String())) - } - }) - } -} - -func TestWriteMetadataHandler(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *api.ContextMock - engine gotenberg.PdfEngine - expectError bool - expectHttpError bool - expectHttpStatus int - expectOutputPathsCount int - expectOutputPaths []string - }{ - { - scenario: "missing at least one mandatory file", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "no metadata form field", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - }) - return ctx - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "invalid metadata form field", - 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{ - "metadata": { - "foo", - }, - }) - return ctx - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "no metadata", - 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{ - "metadata": { - "{}", - }, - }) - return ctx - }(), - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - expectOutputPathsCount: 0, - }, - { - scenario: "error from PDF engine", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - }) - ctx.SetValues(map[string][]string{ - "metadata": { - "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", - }, - }) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { - return errors.New("foo") - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "cannot add output paths", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - }) - ctx.SetValues(map[string][]string{ - "metadata": { - "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", - }, - }) - ctx.SetCancelled(true) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { - return nil - }, - }, - expectError: true, - expectHttpError: false, - expectOutputPathsCount: 0, - }, - { - scenario: "success", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: new(api.Context)} - ctx.SetFiles(map[string]string{ - "file.pdf": "/file.pdf", - }) - ctx.SetValues(map[string][]string{ - "metadata": { - "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", - }, - }) - return ctx - }(), - engine: &gotenberg.PdfEngineMock{ - WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { - return nil - }, - }, - expectError: false, - expectHttpError: false, - expectOutputPathsCount: 1, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - tc.ctx.SetLogger(zap.NewNop()) - c := echo.New().NewContext(nil, nil) - c.Set("context", tc.ctx.Context) - - err := writeMetadataRoute(tc.engine).Handler(c) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } - - for _, path := range tc.expectOutputPaths { - if !slices.Contains(tc.ctx.OutputPaths(), path) { - t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths()) - } - } - }) - } -} diff --git a/pkg/modules/pdftk/doc.go b/pkg/modules/pdftk/doc.go index 3a01ae417..c65403f72 100644 --- a/pkg/modules/pdftk/doc.go +++ b/pkg/modules/pdftk/doc.go @@ -2,6 +2,7 @@ // interface using the PDFtk command-line tool. This package allows for: // // 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 // environment variable. diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index 9846ee9df..e67a087c6 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -1,10 +1,14 @@ package pdftk import ( + "bytes" "context" "errors" "fmt" "os" + "os/exec" + "path/filepath" + "syscall" "go.uber.org/zap" @@ -29,7 +33,7 @@ func (engine *PdfTk) Descriptor() gotenberg.ModuleDescriptor { } } -// Provision sets the modules properties. +// Provision sets the module properties. func (engine *PdfTk) Provision(ctx *gotenberg.Context) error { binPath, ok := os.LookupEnv("PDFTK_BIN_PATH") if !ok { @@ -51,6 +55,57 @@ func (engine *PdfTk) Validate() error { 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. func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { 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) } +// 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. 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) @@ -85,10 +145,59 @@ func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, meta return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Encrypt adds password protection to a PDF file using PDFtk. +func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + if userPassword == "" { + return errors.New("user password cannot be empty") + } + + if ownerPassword == userPassword || ownerPassword == "" { + return gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow") + } + + // Create a temp output file in the same directory. + tmpPath := inputPath + ".tmp" + + var args []string + args = append(args, inputPath) + args = append(args, "output", tmpPath) + args = append(args, "encrypt_128bit") + args = append(args, "user_pw", userPassword) + args = append(args, "owner_pw", ownerPassword) + + cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) + if err != nil { + return fmt.Errorf("create command: %w", err) + } + + _, err = cmd.Exec() + if err != nil { + return fmt.Errorf("encrypt PDF with PDFtk: %w", err) + } + + err = os.Rename(tmpPath, inputPath) + if err != nil { + return fmt.Errorf("rename temporary output file with input file: %w", err) + } + + return nil +} + +// EmbedFiles is not available in this implementation. +func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { + return fmt.Errorf("embed files with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// ImportBookmarks is not available in this implementation. +func (engine *PdfTk) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error { + return fmt.Errorf("import bookmarks into PDF with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Interface guards. var ( _ gotenberg.Module = (*PdfTk)(nil) _ gotenberg.Provisioner = (*PdfTk)(nil) _ gotenberg.Validator = (*PdfTk)(nil) + _ gotenberg.Debuggable = (*PdfTk)(nil) _ gotenberg.PdfEngine = (*PdfTk)(nil) ) diff --git a/pkg/modules/pdftk/pdftk_test.go b/pkg/modules/pdftk/pdftk_test.go deleted file mode 100644 index c7b864eca..000000000 --- a/pkg/modules/pdftk/pdftk_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package pdftk - -import ( - "context" - "errors" - "os" - "reflect" - "testing" - - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestPdfTk_Descriptor(t *testing.T) { - descriptor := new(PdfTk).Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(PdfTk)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestPdfTk_Provision(t *testing.T) { - engine := new(PdfTk) - ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil) - - err := engine.Provision(ctx) - if err != nil { - t.Errorf("expected no error but got: %v", err) - } -} - -func TestPdfTk_Validate(t *testing.T) { - for _, tc := range []struct { - scenario string - binPath string - expectError bool - }{ - { - scenario: "empty bin path", - binPath: "", - expectError: true, - }, - { - scenario: "bin path does not exist", - binPath: "/foo", - expectError: true, - }, - { - scenario: "validate success", - binPath: os.Getenv("PDFTK_BIN_PATH"), - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - engine := new(PdfTk) - engine.binPath = tc.binPath - err := engine.Validate() - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestPdfTk_Merge(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx context.Context - inputPaths []string - expectError bool - }{ - { - scenario: "invalid context", - ctx: nil, - expectError: true, - }, - { - scenario: "invalid input path", - ctx: context.TODO(), - inputPaths: []string{ - "foo", - }, - expectError: true, - }, - { - scenario: "single file success", - ctx: context.TODO(), - inputPaths: []string{ - "/tests/test/testdata/pdfengines/sample1.pdf", - }, - expectError: false, - }, - { - scenario: "many files success", - ctx: context.TODO(), - inputPaths: []string{ - "/tests/test/testdata/pdfengines/sample1.pdf", - "/tests/test/testdata/pdfengines/sample2.pdf", - }, - expectError: false, - }, - } { - 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() - 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) - } - }() - - err = engine.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf") - - 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 TestPdfTk_Convert(t *testing.T) { - engine := new(PdfTk) - err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} - -func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) { - engine := new(PdfTk) - _, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} - -func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) { - engine := new(PdfTk) - err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} diff --git a/pkg/modules/prometheus/prometheus.go b/pkg/modules/prometheus/prometheus.go index 6f54d047e..d899e6b42 100644 --- a/pkg/modules/prometheus/prometheus.go +++ b/pkg/modules/prometheus/prometheus.go @@ -20,7 +20,7 @@ func init() { gotenberg.MustRegisterModule(new(Prometheus)) } -// Prometheus is a module which collects metrics and exposes them via an HTTP +// Prometheus is a module that collects metrics and exposes them via an HTTP // route. type Prometheus struct { namespace string @@ -49,7 +49,7 @@ func (mod *Prometheus) Descriptor() gotenberg.ModuleDescriptor { } } -// Provision sets the modules properties. +// Provision sets the module properties. func (mod *Prometheus) Provision(ctx *gotenberg.Context) error { flags := ctx.ParsedFlags() mod.namespace = flags.MustString("prometheus-namespace") diff --git a/pkg/modules/prometheus/prometheus_test.go b/pkg/modules/prometheus/prometheus_test.go deleted file mode 100644 index 02816b0f7..000000000 --- a/pkg/modules/prometheus/prometheus_test.go +++ /dev/null @@ -1,360 +0,0 @@ -package prometheus - -import ( - "errors" - "reflect" - "testing" - "time" - - "github.com/prometheus/client_golang/prometheus" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestPrometheus_Descriptor(t *testing.T) { - descriptor := new(Prometheus).Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(Prometheus)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestPrometheus_Provision(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx *gotenberg.Context - expectMetrics []gotenberg.Metric - expectError bool - }{ - { - scenario: "disable collect", - ctx: func() *gotenberg.Context { - fs := new(Prometheus).Descriptor().FlagSet - err := fs.Parse([]string{"--prometheus-disable-collect=true"}) - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: fs, - }, - nil, - ) - }(), - expectError: false, - }, - { - scenario: "invalid metrics provider", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - gotenberg.ValidatorMock - gotenberg.MetricsProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.ValidateMock = func() error { - return errors.New("foo") - } - mod.MetricsMock = func() ([]gotenberg.Metric, error) { - return nil, nil - } - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Prometheus).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "invalid metrics from metrics provider", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - gotenberg.ValidatorMock - gotenberg.MetricsProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.ValidateMock = func() error { - return nil - } - mod.MetricsMock = func() ([]gotenberg.Metric, error) { - return nil, errors.New("foo") - } - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Prometheus).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectError: true, - }, - { - scenario: "provision success", - ctx: func() *gotenberg.Context { - mod := &struct { - gotenberg.ModuleMock - gotenberg.ValidatorMock - gotenberg.MetricsProviderMock - }{} - mod.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.ValidateMock = func() error { - return nil - } - mod.MetricsMock = func() ([]gotenberg.Metric, error) { - return []gotenberg.Metric{ - { - Name: "foo", - Description: "Bar.", - }, - }, nil - } - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Prometheus).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectMetrics: []gotenberg.Metric{ - { - Name: "foo", - Description: "Bar.", - }, - }, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := new(Prometheus) - err := mod.Provision(tc.ctx) - - if !reflect.DeepEqual(mod.metrics, tc.expectMetrics) { - t.Fatalf("expected metrics %+v, but got: %+v", tc.expectMetrics, mod.metrics) - } - - 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 TestPrometheus_Validate(t *testing.T) { - for _, tc := range []struct { - scenario string - namespace string - metrics []gotenberg.Metric - disableCollect bool - expectError bool - }{ - { - scenario: "collect disabled", - namespace: "foo", - disableCollect: true, - expectError: false, - }, - { - scenario: "empty namespace", - namespace: "", - disableCollect: false, - expectError: true, - }, - { - scenario: "empty metric name", - namespace: "foo", - metrics: []gotenberg.Metric{ - { - Name: "", - }, - }, - disableCollect: false, - expectError: true, - }, - { - scenario: "nil read metric method", - namespace: "foo", - metrics: []gotenberg.Metric{ - { - Name: "foo", - Read: nil, - }, - }, - disableCollect: false, - expectError: true, - }, - { - scenario: "already registered metric", - namespace: "foo", - metrics: []gotenberg.Metric{ - { - Name: "foo", - Read: func() float64 { - return 0 - }, - }, - { - Name: "foo", - Read: func() float64 { - return 0 - }, - }, - }, - disableCollect: false, - expectError: true, - }, - { - scenario: "validate success", - namespace: "foo", - metrics: []gotenberg.Metric{ - { - Name: "foo", - Read: func() float64 { - return 0 - }, - }, - { - Name: "bar", - Read: func() float64 { - return 0 - }, - }, - }, - disableCollect: false, - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := &Prometheus{ - namespace: tc.namespace, - metrics: tc.metrics, - disableCollect: tc.disableCollect, - } - err := mod.Validate() - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestPrometheus_Start(t *testing.T) { - for _, tc := range []struct { - scenario string - metrics []gotenberg.Metric - disableCollect bool - }{ - { - scenario: "collect disabled", - disableCollect: true, - }, - { - scenario: "start success", - metrics: []gotenberg.Metric{ - { - Name: "foo", - Read: func() float64 { - return 0 - }, - }, - }, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := &Prometheus{ - namespace: "foo", - interval: time.Duration(1) * time.Second, - metrics: tc.metrics, - disableCollect: tc.disableCollect, - registry: prometheus.NewRegistry(), - } - - err := mod.Start() - if err != nil { - t.Errorf("expected no error but got: %v", err) - } - }) - } -} - -func TestPrometheus_StartupMessage(t *testing.T) { - mod := new(Prometheus) - - mod.disableCollect = true - disableCollectMsg := mod.StartupMessage() - - mod.disableCollect = false - noDisableCollectMsg := mod.StartupMessage() - - if disableCollectMsg == noDisableCollectMsg { - t.Errorf("expected differrent startup messages if collect is disabled or not, but got '%s'", disableCollectMsg) - } -} - -func TestPrometheus_Stop(t *testing.T) { - err := new(Prometheus).Stop(nil) - if err != nil { - t.Errorf("expected no error but got: %v", err) - } -} - -func TestPrometheus_Routes(t *testing.T) { - for _, tc := range []struct { - scenario string - disableCollect bool - expectRoutes int - }{ - { - scenario: "collect disabled", - disableCollect: true, - expectRoutes: 0, - }, - { - scenario: "routes not disabled", - disableCollect: false, - expectRoutes: 1, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - mod := &Prometheus{ - disableCollect: tc.disableCollect, - registry: prometheus.NewRegistry(), - } - - routes, err := mod.Routes() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectRoutes != len(routes) { - t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes)) - } - }) - } -} diff --git a/pkg/modules/qpdf/doc.go b/pkg/modules/qpdf/doc.go index 31f61b361..5f494a1f0 100644 --- a/pkg/modules/qpdf/doc.go +++ b/pkg/modules/qpdf/doc.go @@ -2,6 +2,8 @@ // interface using the QPDF command-line tool. This package allows for: // // 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 // environment variable. diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index 57698281d..2d6e7ba7d 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -1,10 +1,14 @@ package qpdf import ( + "bytes" "context" "errors" "fmt" "os" + "os/exec" + "path/filepath" + "syscall" "go.uber.org/zap" @@ -18,7 +22,8 @@ func init() { // QPdf abstracts the CLI tool QPDF and implements the [gotenberg.PdfEngine] // interface. type QPdf struct { - binPath string + binPath string + globalArgs []string } // Descriptor returns a [QPdf]'s module descriptor. @@ -29,7 +34,7 @@ func (engine *QPdf) Descriptor() gotenberg.ModuleDescriptor { } } -// Provision sets the modules properties. +// Provision sets the module properties. func (engine *QPdf) Provision(ctx *gotenberg.Context) error { binPath, ok := os.LookupEnv("QPDF_BIN_PATH") if !ok { @@ -37,6 +42,8 @@ func (engine *QPdf) Provision(ctx *gotenberg.Context) error { } engine.binPath = binPath + // Warnings should not cause errors. + engine.globalArgs = []string{"--warning-exit-0"} return nil } @@ -45,16 +52,71 @@ func (engine *QPdf) Provision(ctx *gotenberg.Context) error { func (engine *QPdf) Validate() error { _, err := os.Stat(engine.binPath) 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 } +// 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. func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { var args []string args = append(args, "--empty") + args = append(args, engine.globalArgs...) args = append(args, "--pages") args = append(args, inputPaths...) 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) } +// 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. 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) @@ -87,9 +172,49 @@ func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, metad return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Encrypt adds password protection to a PDF file using QPDF. +func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + if userPassword == "" { + return errors.New("user password cannot be empty") + } + + if ownerPassword == "" { + ownerPassword = userPassword + } + + var args []string + args = append(args, inputPath) + args = append(args, engine.globalArgs...) + args = append(args, "--replace-input") + args = append(args, "--encrypt", userPassword, ownerPassword, "256", "--") + + cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) + if err != nil { + return fmt.Errorf("create command: %w", err) + } + + _, err = cmd.Exec() + if err != nil { + return fmt.Errorf("encrypt PDF with QPDF: %w", err) + } + + return nil +} + +// EmbedFiles is not available in this implementation. +func (engine *QPdf) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { + return fmt.Errorf("embed files with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// ImportBookmarks is not available in this implementation. +func (engine *QPdf) ImportBookmarks(ctx context.Context, logger *zap.Logger, inputPath, inputBookmarksPath, outputPath string) error { + return fmt.Errorf("import bookmarks into PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + var ( _ gotenberg.Module = (*QPdf)(nil) _ gotenberg.Provisioner = (*QPdf)(nil) _ gotenberg.Validator = (*QPdf)(nil) + _ gotenberg.Debuggable = (*QPdf)(nil) _ gotenberg.PdfEngine = (*QPdf)(nil) ) diff --git a/pkg/modules/qpdf/qpdf_test.go b/pkg/modules/qpdf/qpdf_test.go deleted file mode 100644 index a966928d0..000000000 --- a/pkg/modules/qpdf/qpdf_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package qpdf - -import ( - "context" - "errors" - "os" - "reflect" - "testing" - - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestQPdf_Descriptor(t *testing.T) { - descriptor := new(QPdf).Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(QPdf)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestQPdf_Provision(t *testing.T) { - engine := new(QPdf) - ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil) - - err := engine.Provision(ctx) - if err != nil { - t.Errorf("expected no error but got: %v", err) - } -} - -func TestQPdf_Validate(t *testing.T) { - for _, tc := range []struct { - scenario string - binPath string - expectError bool - }{ - { - scenario: "empty bin path", - binPath: "", - expectError: true, - }, - { - scenario: "bin path does not exist", - binPath: "/foo", - expectError: true, - }, - { - scenario: "validate success", - binPath: os.Getenv("QPDF_BIN_PATH"), - expectError: false, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - engine := new(QPdf) - engine.binPath = tc.binPath - err := engine.Validate() - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectError && err == nil { - t.Fatal("expected error but got none") - } - }) - } -} - -func TestQPdf_Merge(t *testing.T) { - for _, tc := range []struct { - scenario string - ctx context.Context - inputPaths []string - expectError bool - }{ - { - scenario: "invalid context", - ctx: nil, - expectError: true, - }, - { - scenario: "invalid input path", - ctx: context.TODO(), - inputPaths: []string{ - "foo", - }, - expectError: true, - }, - { - scenario: "single file success", - ctx: context.TODO(), - inputPaths: []string{ - "/tests/test/testdata/pdfengines/sample1.pdf", - }, - expectError: false, - }, - { - scenario: "many files success", - ctx: context.TODO(), - inputPaths: []string{ - "/tests/test/testdata/pdfengines/sample1.pdf", - "/tests/test/testdata/pdfengines/sample2.pdf", - }, - 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) - } - - fs := gotenberg.NewFileSystem() - 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) - } - }() - - err = engine.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf") - - 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) { - engine := new(QPdf) - err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} - -func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) { - engine := new(QPdf) - _, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} - -func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) { - engine := new(QPdf) - err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "") - - if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) - } -} diff --git a/pkg/modules/webhook/client.go b/pkg/modules/webhook/client.go index 9ce23b190..39d91bd6b 100644 --- a/pkg/modules/webhook/client.go +++ b/pkg/modules/webhook/client.go @@ -26,14 +26,14 @@ type client struct { } // send call the webhook either to send the success response or the error response. -func (c client) send(body io.Reader, headers map[string]string, erroed bool) error { +func (c client) send(body io.Reader, headers map[string]string, errored bool) error { url := c.url - if erroed { + if errored { url = c.errorUrl } method := c.method - if erroed { + if errored { method = c.errorMethod } @@ -54,9 +54,9 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err contentLength, ok := headers[echo.HeaderContentLength] if ok { // Golang "http" package should automatically calculate the size of the - // body. But, when using a buffered file reader, it does not work. - // Worse, the "Content-Length" header is also removed. Therefore, in - // order to keep this valuable information, we have to trust the caller + // body. But when using a buffered file reader, it does not work. + // Worse, the "Content-Length" header is also removed. Therefore, + // to keep this valuable information, we have to trust the caller // by reading the value of the "Content-Length" entry and set it as the // content length of the request. It's kinda suboptimal, but hey, at // least it works. @@ -100,7 +100,7 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err fields[3] = zap.String("latency_human", finishTime.Sub(c.startTime).String()) fields[4] = zap.Int64("bytes_out", req.ContentLength) - if erroed { + if errored { c.logger.Warn("request to webhook with error details handled", fields...) return nil diff --git a/pkg/modules/webhook/middleware.go b/pkg/modules/webhook/middleware.go index 63150cce9..f5d870d39 100644 --- a/pkg/modules/webhook/middleware.go +++ b/pkg/modules/webhook/middleware.go @@ -20,11 +20,74 @@ import ( "github.com/gotenberg/gotenberg/v8/pkg/modules/api" ) +type sendOutputFileParams struct { + ctx *api.Context + outputPath string + extraHttpHeaders map[string]string + traceHeader string + trace string + client *client + handleError func(error) +} + func webhookMiddleware(w *Webhook) api.Middleware { return api.Middleware{ Stack: api.MultipartStack, Handler: func() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { + sendOutputFile := func(params sendOutputFileParams) { + outputFile, err := os.Open(params.outputPath) + if err != nil { + params.ctx.Log().Error(fmt.Sprintf("open output file: %s", err)) + params.handleError(err) + return + } + defer func() { + err := outputFile.Close() + if err != nil { + params.ctx.Log().Error(fmt.Sprintf("close output file: %s", err)) + } + }() + + fileHeader := make([]byte, 512) + _, err = outputFile.Read(fileHeader) + if err != nil { + params.ctx.Log().Error(fmt.Sprintf("read header of output file: %s", err)) + params.handleError(err) + return + } + + fileStat, err := outputFile.Stat() + if err != nil { + params.ctx.Log().Error(fmt.Sprintf("get stat from output file: %s", err)) + params.handleError(err) + return + } + + _, err = outputFile.Seek(0, 0) + if err != nil { + params.ctx.Log().Error(fmt.Sprintf("reset output file reader: %s", err)) + params.handleError(err) + return + } + + headers := map[string]string{ + echo.HeaderContentType: http.DetectContentType(fileHeader), + echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10), + params.traceHeader: params.trace, + } + _, ok := params.extraHttpHeaders[echo.HeaderContentDisposition] + if !ok { + headers[echo.HeaderContentDisposition] = fmt.Sprintf("attachment; filename=%q", params.ctx.OutputFilename(params.outputPath)) + } + + err = params.client.send(bufio.NewReader(outputFile), headers, false) + if err != nil { + params.ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err)) + params.handleError(err) + } + } + return func(c echo.Context) error { webhookUrl := c.Request().Header.Get("Gotenberg-Webhook-Url") if webhookUrl == "" { @@ -113,7 +176,7 @@ func webhookMiddleware(w *Webhook) api.Middleware { } } - // Retrieve values from echo.Context before it get recycled. + // Retrieve values from echo.Context before it gets recycled. // See https://github.com/gotenberg/gotenberg/issues/1000. startTime := c.Get("startTime").(time.Time) traceHeader := c.Get("traceHeader").(string) @@ -144,7 +207,7 @@ func webhookMiddleware(w *Webhook) api.Middleware { // This method parses an "asynchronous" error and sends a // request to the webhook error URL with a JSON body // containing the status and the error message. - handleAsyncError := func(err error) { + handleError := func(err error) { status, message := api.ParseError(err) body := struct { @@ -158,7 +221,6 @@ func webhookMiddleware(w *Webhook) api.Middleware { b, err := json.Marshal(body) if err != nil { ctx.Log().Error(fmt.Sprintf("marshal JSON: %s", err.Error())) - return } @@ -173,84 +235,88 @@ func webhookMiddleware(w *Webhook) api.Middleware { } } + if w.enableSyncMode { + err := next(c) + if err != nil { + if errors.Is(err, api.ErrNoOutputFile) { + errNoOutputFile := fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err) + handleError(api.WrapError( + errNoOutputFile, + api.NewSentinelHttpError( + http.StatusBadRequest, + "The webhook middleware can only work with multipart/form-data routes that results in output files", + ), + )) + return nil + } + ctx.Log().Error(err.Error()) + handleError(err) + return nil + } + + outputPath, err := ctx.BuildOutputFile() + if err != nil { + ctx.Log().Error(fmt.Sprintf("build output file: %s", err)) + handleError(err) + return nil + } + // No error, let's send the output file to the webhook URL. + sendOutputFile(sendOutputFileParams{ + ctx: ctx, + outputPath: outputPath, + extraHttpHeaders: extraHttpHeaders, + traceHeader: traceHeader, + trace: trace, + client: client, + handleError: handleError, + }) + return c.NoContent(http.StatusNoContent) + } // As a webhook URL has been given, we handle the request in a // goroutine and return immediately. + w.asyncCount.Add(1) go func() { defer cancel() + defer w.asyncCount.Add(-1) // Call the next middleware in the chain. err := next(c) if err != nil { + if errors.Is(err, api.ErrNoOutputFile) { + errNoOutputFile := fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err) + handleError(api.WrapError( + errNoOutputFile, + api.NewSentinelHttpError( + http.StatusBadRequest, + "The webhook middleware can only work with multipart/form-data routes that results in output files", + ), + )) + return + } // The process failed for whatever reason. Let's send the // details to the webhook. ctx.Log().Error(err.Error()) - handleAsyncError(err) - + handleError(err) return } - // No error, let's get build the output file. + // No error, let's get to build the output file. outputPath, err := ctx.BuildOutputFile() if err != nil { ctx.Log().Error(fmt.Sprintf("build output file: %s", err)) - handleAsyncError(err) - + handleError(err) return } - outputFile, err := os.Open(outputPath) - if err != nil { - ctx.Log().Error(fmt.Sprintf("open output file: %s", err)) - handleAsyncError(err) - - return - } - - defer func() { - err := outputFile.Close() - if err != nil { - ctx.Log().Error(fmt.Sprintf("close output file: %s", err)) - } - }() - - fileHeader := make([]byte, 512) - _, err = outputFile.Read(fileHeader) - if err != nil { - ctx.Log().Error(fmt.Sprintf("read header of output file: %s", err)) - handleAsyncError(err) - - return - } - - fileStat, err := outputFile.Stat() - if err != nil { - ctx.Log().Error(fmt.Sprintf("get stat from output file: %s", err)) - handleAsyncError(err) - - return - } - - _, err = outputFile.Seek(0, 0) - if err != nil { - ctx.Log().Error(fmt.Sprintf("reset output file reader: %s", err)) - handleAsyncError(err) - - return - } - - headers := map[string]string{ - echo.HeaderContentDisposition: fmt.Sprintf("attachement; filename=%q", ctx.OutputFilename(outputPath)), - echo.HeaderContentType: http.DetectContentType(fileHeader), - echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10), - traceHeader: trace, - } - - // Send the output file to the webhook. - err = client.send(bufio.NewReader(outputFile), headers, false) - if err != nil { - ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err)) - handleAsyncError(err) - } + sendOutputFile(sendOutputFileParams{ + ctx: ctx, + outputPath: outputPath, + extraHttpHeaders: extraHttpHeaders, + traceHeader: traceHeader, + trace: trace, + client: client, + handleError: handleError, + }) }() return api.ErrAsyncProcess diff --git a/pkg/modules/webhook/middleware_test.go b/pkg/modules/webhook/middleware_test.go deleted file mode 100644 index 8940f4069..000000000 --- a/pkg/modules/webhook/middleware_test.go +++ /dev/null @@ -1,582 +0,0 @@ -package webhook - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "math/rand" - "mime/multipart" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/dlclark/regexp2" - "github.com/labstack/echo/v4" - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v8/pkg/modules/api" -) - -func TestWebhookMiddlewareGuards(t *testing.T) { - buildMultipartFormDataRequest := func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - - err := writer.WriteField("foo", "foo") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - - return req - } - - buildWebhookModule := func() *Webhook { - return &Webhook{ - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - errorAllowList: regexp2.MustCompile("", 0), - errorDenyList: regexp2.MustCompile("", 0), - maxRetry: 0, - retryMinWait: 0, - retryMaxWait: 0, - disable: false, - } - } - - for _, tc := range []struct { - scenario string - request *http.Request - mod *Webhook - next echo.HandlerFunc - noDeadline bool - expectError bool - expectHttpError bool - expectHttpStatus int - }{ - { - scenario: "no webhook URL, skip middleware", - request: buildMultipartFormDataRequest(), - mod: buildWebhookModule(), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - return nil - } - }(), - noDeadline: false, - expectError: false, - expectHttpError: false, - }, - { - scenario: "no webhook error URL", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Webhook-Url", "foo") - return req - }(), - mod: buildWebhookModule(), - noDeadline: false, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "context has no deadline", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Webhook-Url", "foo") - req.Header.Set("Gotenberg-Webhook-Error-Url", "bar") - return req - }(), - mod: buildWebhookModule(), - noDeadline: true, - expectError: true, - }, - { - scenario: "webhook URL is not allowed", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Webhook-Url", "foo") - req.Header.Set("Gotenberg-Webhook-Error-Url", "bar") - return req - }(), - mod: func() *Webhook { - mod := buildWebhookModule() - mod.allowList = regexp2.MustCompile("bar", 0) - return mod - }(), - noDeadline: false, - expectError: true, - }, - { - scenario: "webhook URL is denied", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Webhook-Url", "foo") - req.Header.Set("Gotenberg-Webhook-Error-Url", "bar") - return req - }(), - mod: func() *Webhook { - mod := buildWebhookModule() - mod.denyList = regexp2.MustCompile("foo", 0) - return mod - }(), - noDeadline: false, - expectError: true, - }, - { - scenario: "webhook error URL is not allowed", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Webhook-Url", "foo") - req.Header.Set("Gotenberg-Webhook-Error-Url", "bar") - return req - }(), - mod: func() *Webhook { - mod := buildWebhookModule() - mod.errorAllowList = regexp2.MustCompile("foo", 0) - return mod - }(), - noDeadline: false, - expectError: true, - }, - { - scenario: "webhook error URL is denied", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Webhook-Url", "foo") - req.Header.Set("Gotenberg-Webhook-Error-Url", "bar") - return req - }(), - mod: func() *Webhook { - mod := buildWebhookModule() - mod.errorDenyList = regexp2.MustCompile("bar", 0) - return mod - }(), - noDeadline: false, - expectError: true, - }, - { - scenario: "invalid webhook method (GET)", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Webhook-Url", "foo") - req.Header.Set("Gotenberg-Webhook-Method", http.MethodGet) - req.Header.Set("Gotenberg-Webhook-Error-Url", "bar") - return req - }(), - mod: buildWebhookModule(), - noDeadline: false, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "invalid webhook error method (GET)", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Webhook-Url", "foo") - req.Header.Set("Gotenberg-Webhook-Error-Url", "bar") - req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet) - return req - }(), - mod: buildWebhookModule(), - noDeadline: false, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "valid webhook method (POST) but invalid webhook error method (GET)", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Webhook-Url", "foo") - req.Header.Set("Gotenberg-Webhook-Method", http.MethodPost) - req.Header.Set("Gotenberg-Webhook-Error-Url", "bar") - req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet) - return req - }(), - mod: buildWebhookModule(), - noDeadline: false, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "valid webhook method (PATH) but invalid webhook error method (GET)", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Webhook-Url", "foo") - req.Header.Set("Gotenberg-Webhook-Method", http.MethodPatch) - req.Header.Set("Gotenberg-Webhook-Error-Url", "bar") - req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet) - return req - }(), - mod: buildWebhookModule(), - noDeadline: false, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "valid webhook method (PUT) but invalid webhook error method (GET)", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Webhook-Url", "foo") - req.Header.Set("Gotenberg-Webhook-Method", http.MethodPut) - req.Header.Set("Gotenberg-Webhook-Error-Url", "bar") - req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet) - return req - }(), - mod: buildWebhookModule(), - noDeadline: false, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - { - scenario: "invalid webhook extra HTTP headers", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Webhook-Url", "foo") - req.Header.Set("Gotenberg-Webhook-Error-Url", "bar") - req.Header.Set("Gotenberg-Webhook-Extra-Http-Headers", "foo") - return req - }(), - mod: buildWebhookModule(), - noDeadline: false, - expectError: true, - expectHttpError: true, - expectHttpStatus: http.StatusBadRequest, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - srv := echo.New() - srv.HideBanner = true - srv.HidePort = true - - c := srv.NewContext(tc.request, httptest.NewRecorder()) - - if tc.noDeadline { - ctx := &api.ContextMock{Context: &api.Context{Context: context.Background()}} - ctx.SetEchoContext(c) - c.Set("context", ctx.Context) - c.Set("cancel", func() context.CancelFunc { - return nil - }()) - } else { - timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second) - ctx := &api.ContextMock{Context: &api.Context{Context: timeoutCtx}} - ctx.SetEchoContext(c) - c.Set("context", ctx.Context) - c.Set("cancel", cancel) - } - - err := webhookMiddleware(tc.mod).Handler(tc.next)(c) - - if tc.expectError && err == nil { - t.Fatal("expected error but got none", err) - } - - if !tc.expectError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - var httpErr api.HttpError - isHttpError := errors.As(err, &httpErr) - - if tc.expectHttpError && !isHttpError { - t.Errorf("expected an HTTP error but got: %v", err) - } - - if !tc.expectHttpError && isHttpError { - t.Errorf("expected no HTTP error but got one: %v", httpErr) - } - - if err != nil && tc.expectHttpError && isHttpError { - status, _ := httpErr.HttpError() - if status != tc.expectHttpStatus { - t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) - } - } - }) - } -} - -func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) { - buildMultipartFormDataRequest := func() *http.Request { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - defer func() { - err := writer.Close() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - }() - - err := writer.WriteField("foo", "foo") - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) - - return req - } - - buildWebhookModule := func() *Webhook { - return &Webhook{ - allowList: regexp2.MustCompile("", 0), - denyList: regexp2.MustCompile("", 0), - errorAllowList: regexp2.MustCompile("", 0), - errorDenyList: regexp2.MustCompile("", 0), - maxRetry: 0, - retryMinWait: 0, - retryMaxWait: 0, - clientTimeout: time.Duration(30) * time.Second, - disable: false, - } - } - - for _, tc := range []struct { - scenario string - request *http.Request - mod *Webhook - next echo.HandlerFunc - expectWebhookContentType string - expectWebhookMethod string - expectWebhookExtraHttpHeaders map[string]string - expectWebhookFilename string - expectWebhookErrorStatus int - expectWebhookErrorMessage string - returnedError *echo.HTTPError - }{ - { - scenario: "next handler return an error", - request: buildMultipartFormDataRequest(), - mod: buildWebhookModule(), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - return errors.New("foo") - } - }(), - expectWebhookContentType: echo.MIMEApplicationJSON, - expectWebhookMethod: http.MethodPost, - expectWebhookErrorStatus: http.StatusInternalServerError, - expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError), - }, - { - scenario: "next handler return an HTTP error", - request: buildMultipartFormDataRequest(), - mod: buildWebhookModule(), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - return api.NewSentinelHttpError(http.StatusBadRequest, http.StatusText(http.StatusBadRequest)) - } - }(), - expectWebhookContentType: echo.MIMEApplicationJSON, - expectWebhookMethod: http.MethodPost, - expectWebhookErrorStatus: http.StatusBadRequest, - expectWebhookErrorMessage: http.StatusText(http.StatusBadRequest), - }, - { - scenario: "success", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Output-Filename", "foo") - req.Header.Set("Gotenberg-Webhook-Extra-Http-Headers", `{ "foo": "bar" }`) - return req - }(), - mod: buildWebhookModule(), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - ctx := c.Get("context").(*api.Context) - return ctx.AddOutputPaths("/tests/test/testdata/api/sample2.pdf") - } - }(), - expectWebhookContentType: "application/pdf", - expectWebhookMethod: http.MethodPost, - expectWebhookFilename: "foo", - expectWebhookExtraHttpHeaders: map[string]string{"foo": "bar"}, - }, - { - scenario: "success (return an error)", - request: func() *http.Request { - req := buildMultipartFormDataRequest() - req.Header.Set("Gotenberg-Output-Filename", "foo") - return req - }(), - mod: buildWebhookModule(), - next: func() echo.HandlerFunc { - return func(c echo.Context) error { - ctx := c.Get("context").(*api.Context) - return ctx.AddOutputPaths("/tests/test/testdata/api/sample1.pdf") - } - }(), - returnedError: echo.ErrInternalServerError, - expectWebhookContentType: echo.MIMEApplicationJSON, - expectWebhookMethod: http.MethodPost, - expectWebhookErrorStatus: http.StatusInternalServerError, - expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError), - expectWebhookFilename: "foo", - }, - } { - func() { - srv := echo.New() - srv.HideBanner = true - srv.HidePort = true - - c := srv.NewContext(tc.request, httptest.NewRecorder()) - c.Set("logger", zap.NewNop()) - c.Set("traceHeader", "Gotenberg-Trace") - c.Set("trace", "foo") - c.Set("startTime", time.Now()) - - timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second) - ctx := &api.ContextMock{Context: &api.Context{Context: timeoutCtx}} - ctx.SetLogger(zap.NewNop()) - ctx.SetEchoContext(c) - - c.Set("context", ctx.Context) - c.Set("cancel", cancel) - - webhook := echo.New() - webhook.HideBanner = true - webhook.HidePort = true - webhookPort := rand.Intn(65535-1025+1) + 1025 - - c.Request().Header.Set("Gotenberg-Webhook-Url", fmt.Sprintf("http://localhost:%d/", webhookPort)) - c.Request().Header.Set("Gotenberg-Webhook-Error-Url", fmt.Sprintf("http://localhost:%d/", webhookPort)) - - errChan := make(chan error, 1) - - webhook.POST( - "/", - func() echo.HandlerFunc { - return func(c echo.Context) error { - contentType := c.Request().Header.Get(echo.HeaderContentType) - if contentType != tc.expectWebhookContentType { - t.Errorf("expected '%s' '%s' but got '%s'", echo.HeaderContentType, tc.expectWebhookContentType, contentType) - } - - trace := c.Request().Header.Get("Gotenberg-Trace") - if trace != "foo" { - t.Errorf("expected '%s' '%s' but got '%s'", "Gotenberg-Trace", "foo", trace) - } - - method := c.Request().Method - if method != tc.expectWebhookMethod { - t.Errorf("expected HTTP method '%s' but got '%s'", tc.expectWebhookMethod, method) - } - - for key, expect := range tc.expectWebhookExtraHttpHeaders { - actual := c.Request().Header.Get(key) - - if actual != expect { - t.Errorf("expected '%s' '%s' but got '%s'", key, expect, actual) - } - } - - if contentType == echo.MIMEApplicationJSON { - body, err := io.ReadAll(c.Request().Body) - if err != nil { - errChan <- err - return nil - } - - result := struct { - Status int `json:"status"` - Message string `json:"message"` - }{} - - err = json.Unmarshal(body, &result) - if err != nil { - errChan <- err - return nil - } - - if result.Status != tc.expectWebhookErrorStatus { - t.Errorf("expected status %d from JSON but got %d", tc.expectWebhookErrorStatus, result.Status) - } - - if result.Message != tc.expectWebhookErrorMessage { - t.Errorf("expected message '%s' from JSON but got '%s'", tc.expectWebhookErrorMessage, result.Message) - } - - errChan <- nil - return nil - } - - contentLength := c.Request().Header.Get(echo.HeaderContentLength) - if contentLength == "" { - t.Errorf("expected non empty '%s'", echo.HeaderContentLength) - } - - contentDisposition := c.Request().Header.Get(echo.HeaderContentDisposition) - if !strings.Contains(contentDisposition, tc.expectWebhookFilename) { - t.Errorf("expected '%s' '%s' to contain '%s'", echo.HeaderContentDisposition, contentDisposition, tc.expectWebhookFilename) - } - - body, err := io.ReadAll(c.Request().Body) - if err != nil { - errChan <- err - return nil - } - - if body == nil || len(body) == 0 { - t.Error("expected non nil body") - } - - errChan <- nil - - if tc.returnedError != nil { - return tc.returnedError - } - - return nil - } - }(), - ) - - go func() { - err := webhook.Start(fmt.Sprintf(":%d", webhookPort)) - if !errors.Is(err, http.ErrServerClosed) { - t.Errorf("expected no error but got: %v", err) - } - }() - - defer func() { - err := webhook.Shutdown(context.TODO()) - if err != nil { - t.Errorf("expected no error but got: %v", err) - } - }() - - err := webhookMiddleware(tc.mod).Handler(tc.next)(c) - if err != nil && !errors.Is(err, api.ErrAsyncProcess) { - t.Errorf("expected no error but got: %v", err) - } - - err = <-errChan - if err != nil { - t.Errorf("expected no error but got: %v", err) - } - }() - } -} diff --git a/pkg/modules/webhook/webhook.go b/pkg/modules/webhook/webhook.go index 866122096..dd660ce88 100644 --- a/pkg/modules/webhook/webhook.go +++ b/pkg/modules/webhook/webhook.go @@ -1,6 +1,7 @@ package webhook import ( + "sync/atomic" "time" "github.com/dlclark/regexp2" @@ -14,9 +15,10 @@ func init() { gotenberg.MustRegisterModule(new(Webhook)) } -// Webhook is a module which provides a middleware for uploading output files +// Webhook is a module that provides a middleware for uploading output files // to any destinations in an asynchronous fashion. type Webhook struct { + enableSyncMode bool allowList *regexp2.Regexp denyList *regexp2.Regexp errorAllowList *regexp2.Regexp @@ -25,6 +27,7 @@ type Webhook struct { retryMinWait time.Duration retryMaxWait time.Duration clientTimeout time.Duration + asyncCount atomic.Int64 disable bool } @@ -34,6 +37,7 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor { ID: "webhook", FlagSet: func() *flag.FlagSet { fs := flag.NewFlagSet("webhook", flag.ExitOnError) + fs.Bool("webhook-enable-sync-mode", false, "Enable synchronous mode for the webhook feature") fs.String("webhook-allow-list", "", "Set the allowed URLs for the webhook feature using a regular expression") fs.String("webhook-deny-list", "", "Set the denied URLs for the webhook feature using a regular expression") fs.String("webhook-error-allow-list", "", "Set the allowed URLs in case of an error for the webhook feature using a regular expression") @@ -53,6 +57,7 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor { // Provision sets the module properties. func (w *Webhook) Provision(ctx *gotenberg.Context) error { flags := ctx.ParsedFlags() + w.enableSyncMode = flags.MustBool("webhook-enable-sync-mode") w.allowList = flags.MustRegexp("webhook-allow-list") w.denyList = flags.MustRegexp("webhook-deny-list") w.errorAllowList = flags.MustRegexp("webhook-error-allow-list") @@ -62,6 +67,7 @@ func (w *Webhook) Provision(ctx *gotenberg.Context) error { w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait") w.clientTimeout = flags.MustDuration("webhook-client-timeout") w.disable = flags.MustBool("webhook-disable") + w.asyncCount.Store(0) return nil } @@ -77,9 +83,15 @@ func (w *Webhook) Middlewares() ([]api.Middleware, error) { }, nil } +// AsyncCount returns the number of asynchronous requests. +func (w *Webhook) AsyncCount() int64 { + return w.asyncCount.Load() +} + // Interface guards. var ( - _ gotenberg.Module = (*Webhook)(nil) - _ gotenberg.Provisioner = (*Webhook)(nil) - _ api.MiddlewareProvider = (*Webhook)(nil) + _ gotenberg.Module = (*Webhook)(nil) + _ gotenberg.Provisioner = (*Webhook)(nil) + _ api.MiddlewareProvider = (*Webhook)(nil) + _ api.AsynchronousCounter = (*Webhook)(nil) ) diff --git a/pkg/modules/webhook/webhook_test.go b/pkg/modules/webhook/webhook_test.go deleted file mode 100644 index 03928046e..000000000 --- a/pkg/modules/webhook/webhook_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package webhook - -import ( - "reflect" - "testing" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func TestWebhook_Descriptor(t *testing.T) { - descriptor := new(Webhook).Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(Webhook)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestWebhook_Provision(t *testing.T) { - mod := new(Webhook) - ctx := gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Webhook).Descriptor().FlagSet, - }, - nil, - ) - - err := mod.Provision(ctx) - if err != nil { - t.Errorf("expected no error but got: %v", err) - } -} - -func TestWebhook_Middlewares(t *testing.T) { - for _, tc := range []struct { - scenario string - disable bool - expectMiddlewares int - }{ - { - scenario: "webhook disabled", - disable: true, - expectMiddlewares: 0, - }, - { - scenario: "webhook enabled", - disable: false, - expectMiddlewares: 1, - }, - } { - mod := new(Webhook) - mod.disable = tc.disable - - middlewares, err := mod.Middlewares() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if tc.expectMiddlewares != len(middlewares) { - t.Errorf("expected %d middlewares but got %d", tc.expectMiddlewares, len(middlewares)) - } - } -} diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100755 index f9c014ff9..000000000 --- a/scripts/release.sh +++ /dev/null @@ -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 . diff --git a/test/Dockerfile b/test/Dockerfile deleted file mode 100644 index 5011f5066..000000000 --- a/test/Dockerfile +++ /dev/null @@ -1,49 +0,0 @@ -ARG GOLANG_VERSION -ARG DOCKER_REGISTRY -ARG DOCKER_REPOSITORY -ARG GOTENBERG_VERSION -ARG GOLANGCI_LINT_VERSION - -FROM golang:$GOLANG_VERSION-bookworm AS golang - -# We're extending the Gotenberg's Docker image because our code relies on external -# dependencies like Google Chrome, LibreOffice, etc. -FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION - -USER root - -ENV GOPATH=/go -ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH -ENV CGO_ENABLED=1 - -COPY --from=golang /usr/local/go /usr/local/go - -RUN apt-get update -qq &&\ - apt-get install -y -qq --no-install-recommends \ - sudo \ - # gcc for cgo. - g++ \ - gcc \ - libc6-dev \ - make \ - pkg-config &&\ - rm -rf /var/lib/apt/lists/* &&\ - mkdir -p "$GOPATH/src" "$GOPATH/bin" &&\ - chmod -R 777 "$GOPATH" &&\ - adduser gotenberg sudo &&\ - echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers &&\ - # We cannot use $PATH in the next command (print $PATH instead of the environment variable value). - sed -i 's#/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin#/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/go/bin:/usr/local/go/bin#g' /etc/sudoers &&\ - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin $GOLANGCI_LINT_VERSION &&\ - go install mvdan.cc/gofumpt@latest &&\ - go install github.com/daixiang0/gci@latest - -COPY ./test/docker-entrypoint.sh /usr/bin/docker-entrypoint.sh -COPY ./test/golint.sh /usr/bin/golint -COPY ./test/gotest.sh /usr/bin/gotest -COPY ./test/gotodos.sh /usr/bin/gotodos - -# Pristine working directory. -WORKDIR /tests - -ENTRYPOINT [ "/usr/bin/tini", "--", "docker-entrypoint.sh" ] \ No newline at end of file diff --git a/test/docker-entrypoint.sh b/test/docker-entrypoint.sh deleted file mode 100755 index 4862f516d..000000000 --- a/test/docker-entrypoint.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash - -# This entrypoint allows us to set the UID and GID of the host user so that -# our testing environment does not override files permissions from the host. -# Credits: https://github.com/thecodingmachine/docker-images-php. - -set +e -mkdir -p testing_file_system_rights.foo -chmod 700 testing_file_system_rights.foo -su gotenberg -c "touch testing_file_system_rights.foo/foo > /dev/null 2>&1" -HAS_CONSISTENT_RIGHTS=$? - -if [[ "$HAS_CONSISTENT_RIGHTS" != "0" ]]; then - # If not specified, the DOCKER_USER is the owner of the current working directory (heuristic!). - DOCKER_USER=$(stat -c '%u' "$(pwd)") -else - # macOs or Windows. - # Note: in most cases, we don't care about the rights (they are not respected). - FILE_OWNER=$(stat -c '%u' "testing_file_system_rights.foo/foo") - if [[ "$FILE_OWNER" == "0" ]]; then - # If root, we are likely on a Windows host. - # All files will belong to root, but it does not matter as everybody can write/delete - # those (0777 access rights). - DOCKER_USER=gotenberg - else - # In case of a NFS mount (common on macOS), the created files will belong to the NFS user. - DOCKER_USER=$FILE_OWNER - fi -fi - -rm -rf testing_file_system_rights.foo -set -e -unset HAS_CONSISTENT_RIGHTS - -# Note: DOCKER_USER is either a username (if the user exists in the container), -# otherwise a user ID (a user from the host). - -# DOCKER_USER is an ID. -if [[ "$DOCKER_USER" =~ ^[0-9]+$ ]] ; then - # Let's change the gotenberg user's ID in order to match this free ID. - usermod -u "$DOCKER_USER" -G sudo gotenberg - DOCKER_USER=gotenberg -fi - -DOCKER_USER_ID=$(id -ur $DOCKER_USER) - -# Fix access rights to stdout and stderr. -set +e -chown $DOCKER_USER /proc/self/fd/{1,2} -set -e - -# Install modules. -set -x -go mod download -go mod tidy -set +x - -# Run the command with the correct user. -exec "sudo" "-E" "-H" "-u" "#$DOCKER_USER_ID" "$@" diff --git a/test/golint.sh b/test/golint.sh deleted file mode 100755 index 107c7018c..000000000 --- a/test/golint.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -x - -golangci-lint run \ No newline at end of file diff --git a/test/gotest.sh b/test/gotest.sh deleted file mode 100755 index 99bb64332..000000000 --- a/test/gotest.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -set -x - -go test -race -covermode=atomic -coverprofile=/tests/coverage.txt ./... -RESULT=$? - -go tool cover -html=coverage.txt -o /tests/coverage.html - -exit $RESULT \ No newline at end of file diff --git a/test/gotodos.sh b/test/gotodos.sh deleted file mode 100755 index c6201369c..000000000 --- a/test/gotodos.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -set -x - -golangci-lint run \ - --no-config \ - --disable-all \ - --enable godox \ No newline at end of file diff --git a/test/integration/doc.go b/test/integration/doc.go new file mode 100644 index 000000000..d52de84a7 --- /dev/null +++ b/test/integration/doc.go @@ -0,0 +1,2 @@ +// Package integration contains everything related to integration testing. +package integration diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature new file mode 100644 index 000000000..d9306fd75 --- /dev/null +++ b/test/integration/features/chromium_convert_html.feature @@ -0,0 +1,997 @@ +# TODO: +# 1. JavaScript disabled on some feature. + +@chromium +@chromium-convert-html +Feature: /forms/chromium/convert/html + + Scenario: POST /forms/chromium/convert/html (Default) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + + Scenario: POST /forms/chromium/convert/html (Single Page) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-12-html/index.html | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 12 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should have the following content at page 12: + """ + Page 12 + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-12-html/index.html | file | + | singlePage | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should NOT have the following content at page 1: + # page-break-after: always; tells the browser's print engine to force a page break after each element, + # even when calculating a large enough paper height, Chromium's PDF rendering will still honor those page break + # directives. + """ + Page 12 + """ + + Scenario: POST /forms/chromium/convert/html (Landscape) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should NOT be set to landscape orientation + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | landscape | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should be set to landscape orientation + + Scenario: POST /forms/chromium/convert/html (Native Page Ranges) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-12-html/index.html | file | + | nativePageRanges | 2-3 | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + + Scenario: POST /forms/chromium/convert/html (Header & Footer) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-12-html/index.html | file | + | files | testdata/header-footer-html/header.html | file | + | files | testdata/header-footer-html/footer.html | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 12 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Pages 12 + """ + Then the "foo.pdf" PDF should have the following content at page 1: + """ + 1 of 12 + """ + Then the "foo.pdf" PDF should have the following content at page 12: + """ + Pages 12 + """ + Then the "foo.pdf" PDF should have the following content at page 12: + """ + 12 of 12 + """ + + Scenario: POST /forms/chromium/convert/html (Wait Delay) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | waitDelay | 2.5s | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. + """ + + Scenario: POST /forms/chromium/convert/html (Wait For Expression) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | waitForExpression | window.globalVar === 'ready' | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. + """ + + Scenario: POST /forms/chromium/convert/html (Emulated Media Type) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Emulated media type is 'print'. + """ + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Emulated media type is 'screen'. + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | emulatedMediaType | print | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Emulated media type is 'print'. + """ + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Emulated media type is 'screen'. + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | emulatedMediaType | screen | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Emulated media type is 'screen'. + """ + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Emulated media type is 'print'. + """ + + Scenario: POST /forms/chromium/convert/html (Default Allow / Deny Lists) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the Gotenberg container should log the following entries: + | 'file:///etc/passwd' matches the expression from the denied list | + + Scenario: POST /forms/chromium/convert/html (Main URL does NOT match allowed list) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + Then the response status code should be 403 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Forbidden + """ + + Scenario: POST /forms/chromium/convert/html (Main URL does match denied list) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | | + | CHROMIUM_DENY_LIST | ^file:///tmp.* | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + Then the response status code should be 403 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Forbidden + """ + + Scenario: POST /forms/chromium/convert/html (Request does not match the allowed list) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | ^file:///tmp.* | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the Gotenberg container should log the following entries: + | 'file:///etc/passwd' does not match the expression from the allowed list | + + Scenario: POST /forms/chromium/convert/html (JavaScript Enabled) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + JavaScript is enabled. + """ + + Scenario: POST /forms/chromium/convert/html (JavaScript Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_DISABLE_JAVASCRIPT | true | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + JavaScript is enabled. + """ + + Scenario: POST /forms/chromium/convert/html (Fail On Resource HTTP Status Codes) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | failOnResourceHttpStatusCodes | [499,599] | field | + Then the response status code should be 409 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid HTTP status code from resources: + https://gethttpstatus.com/400 - 400: Bad Request + """ + + Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | failOnResourceLoadingFailed | true | field | + Then the response status code should be 409 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should contain string: + """ + Chromium failed to load resources + """ + Then the response body should contain string: + """ + resource Stylesheet: net::ERR_CONNECTION_REFUSED + """ + Then the response body should contain string: + """ + resource Stylesheet: net::ERR_FILE_NOT_FOUND + """ + + Scenario: POST /forms/chromium/convert/html (Fail On Console Exceptions) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | failOnConsoleExceptions | true | field | + Then the response status code should be 409 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should contain string: + """ + Chromium console exceptions + """ + Then the response body should contain string: + """ + Error: Exception 1 + """ + Then the response body should contain string: + """ + Error: Exception 2 + """ + + Scenario: POST /forms/chromium/convert/html (Bad Request) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | singlePage | foo | field | + | paperWidth | foo | field | + | paperHeight | foo | field | + | marginTop | foo | field | + | marginBottom | foo | field | + | marginLeft | foo | field | + | marginRight | foo | field | + | preferCssPageSize | foo | field | + | generateDocumentOutline | foo | field | + | generateTaggedPdf | foo | field | + | printBackground | foo | field | + | omitBackground | foo | field | + | landscape | foo | field | + | scale | foo | field | + | waitDelay | foo | field | + | emulatedMediaType | foo | field | + | failOnHttpStatusCodes | foo | field | + | failOnResourceHttpStatusCodes | foo | field | + | failOnResourceLoadingFailed | foo | field | + | failOnConsoleExceptions | foo | field | + | skipNetworkIdleEvent | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'skipNetworkIdleEvent' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnResourceHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceLoadingFailed' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnConsoleExceptions' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'waitDelay' is invalid (got 'foo', resulting to time: invalid duration "foo"); form field 'emulatedMediaType' is invalid (got 'foo', resulting to wrong value, expected either 'screen', 'print' or empty); form field 'omitBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'printBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'scale' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'singlePage' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'paperWidth' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'paperHeight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginTop' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginBottom' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginLeft' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginRight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'preferCssPageSize' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateDocumentOutline' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateTaggedPdf' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form file 'index.html' is required + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | omitBackground | true | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + omitBackground requires printBackground set to true + """ + # Does not seems to happen on amd architectures anymore since Chromium 137. + # See: https://github.com/gotenberg/gotenberg/actions/runs/15384321883/job/43280184372. + # When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + # | files | testdata/page-1-html/index.html | file | + # | paperWidth | 0 | field | + # | paperHeight | 0 | field | + # | marginTop | 1000000 | field | + # | marginBottom | 1000000 | field | + # | marginLeft | 1000000 | field | + # | marginRight | 1000000 | field | + # Then the response status code should be 400 + # Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + # Then the response body should match string: + # """ + # Chromium does not handle the provided settings; please check for aberrant form values + # """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | nativePageRanges | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Chromium does not handle the page ranges 'foo' (nativePageRanges) syntax + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | nativePageRanges | 2-3 | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + The page ranges '2-3' (nativePageRanges) exceeds the page count + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | waitForExpression | undefined | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + The expression 'undefined' (waitForExpression) returned an exception or undefined + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | cookies | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'cookies' is invalid (got 'foo', resulting to unmarshal cookies: invalid character 'o' in literal false (expecting 'a')) + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | cookies | [{"name":"yummy_cookie","value":"choco"}] | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'cookies' is invalid (got '[{"name":"yummy_cookie","value":"choco"}]', resulting to cookie 0 must have its name, value and domain set) + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | extraHttpHeaders | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'extraHttpHeaders' is invalid (got 'foo', resulting to unmarshal extraHttpHeaders: invalid character 'o' in literal false (expecting 'a')) + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | extraHttpHeaders | {"foo":"bar;scope;;"} | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope;;"}', resulting to invalid scope '' for header 'foo') + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | extraHttpHeaders | {"foo":"bar;scope=*."} | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`) + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | splitMode | foo | field | + | splitSpan | 2 | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages') + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | splitMode | intervals | field | + | splitSpan | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax) + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | splitMode | pages | field | + | splitSpan | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | pdfa | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | pdfua | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | metadata | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a')) + """ + + @split + Scenario: POST /forms/chromium/convert/html (Split Intervals) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-3-html/index.html | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | *_0.pdf | + | *_1.pdf | + Then the "*_0.pdf" PDF should have 2 page(s) + Then the "*_1.pdf" PDF should have 1 page(s) + Then the "*_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "*_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "*_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + # See https://github.com/gotenberg/gotenberg/issues/1130. + @split + @output-filename + Scenario: POST /forms/chromium/convert/html (Split Output Filename) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-3-html/index.html | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.zip | + | foo_0.pdf | + | foo_1.pdf | + Then the "foo_0.pdf" PDF should have 2 page(s) + Then the "foo_1.pdf" PDF should have 1 page(s) + Then the "foo_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "foo_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + @split + Scenario: POST /forms/chromium/convert/html (Split Pages) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-3-html/index.html | file | + | splitMode | pages | field | + | splitSpan | 2- | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | *_0.pdf | + | *_1.pdf | + Then the "*_0.pdf" PDF should have 1 page(s) + Then the "*_1.pdf" PDF should have 1 page(s) + Then the "*_0.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "*_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + @split + Scenario: POST /forms/chromium/convert/html (Split Pages & Unify) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-3-html/index.html | file | + | splitMode | pages | field | + | splitSpan | 2- | field | + | splitUnify | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + + @split + Scenario: POST /forms/chromium/convert/html (Split Many PDFs - Lot of Pages) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-12-html/index.html | file | + | splitMode | intervals | field | + | splitSpan | 1 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 12 PDF(s) in the response + Then there should be the following file(s) in the response: + | *_0.pdf | + | *_1.pdf | + | *_2.pdf | + | *_3.pdf | + | *_4.pdf | + | *_5.pdf | + | *_6.pdf | + | *_7.pdf | + | *_8.pdf | + | *_9.pdf | + | *_10.pdf | + | *_11.pdf | + Then the "*_0.pdf" PDF should have 1 page(s) + Then the "*_11.pdf" PDF should have 1 page(s) + Then the "*_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "*_11.pdf" PDF should have the following content at page 1: + """ + Page 12 + """ + + @convert + Scenario: POST /forms/chromium/convert/html (PDF/A-1b & PDF/UA-1) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + @convert + @split + Scenario: POST /forms/chromium/convert/html (Split & PDF/A-1b & PDF/UA-1) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-3-html/index.html | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | *_0.pdf | + | *_1.pdf | + Then the "*_0.pdf" PDF should have 2 page(s) + Then the "*_1.pdf" PDF should have 1 page(s) + Then the "*_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "*_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "*_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + # See https://github.com/gotenberg/gotenberg/issues/1130. + @convert + @split + @output-filename + Scenario: POST /forms/chromium/convert/html (Split & PDF/A-1b & PDF/UA-1 & Output Filename) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-3-html/index.html | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.zip | + | foo_0.pdf | + | foo_1.pdf | + Then the "foo_0.pdf" PDF should have 2 page(s) + Then the "foo_1.pdf" PDF should have 1 page(s) + Then the "foo_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "foo_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + @metadata + Scenario: POST /forms/chromium/convert/html (Metadata) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "foo.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + @flatten + Scenario: POST /forms/chromium/convert/html (Flatten) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | flatten | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be flatten + + @encrypt + Scenario: POST /forms/chromium/convert/html (Encrypt - user password only) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | userPassword | foo | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + @encrypt + Scenario: POST /forms/chromium/convert/html (Encrypt - both user and owner passwords) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | userPassword | foo | field | + | ownerPassword | bar | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + @embed + Scenario: POST /forms/chromium/convert/html (Embeds) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should have the "embed_2.xml" file embedded + + # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. + @convert + @metadata + @flatten + @embed + Scenario: POST /forms/chromium/convert/html (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | flatten | true | field | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 9 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) + Then the response PDF(s) should be flatten + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should have the "embed_2.xml" file embedded + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "foo.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + Scenario: POST /forms/chromium/convert/html (Routes Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_DISABLE_ROUTES | true | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + Then the response status code should be 404 + + Scenario: POST /forms/chromium/convert/html (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | Gotenberg-Trace | forms_chromium_convert_html | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_html" + Then the Gotenberg container should log the following entries: + | "trace":"forms_chromium_convert_html" | + + @download-from + Scenario: POST /forms/chromium/convert/html (Download From) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page-1-html/index.html","extraHttpHeaders":{"X-Foo":"bar"}}] | field | + Then the response status code should be 200 + Then the file request header "X-Foo" should be "bar" + Then the response header "Content-Type" should be "application/pdf" + + @webhook + Scenario: POST /forms/chromium/convert/html (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | Gotenberg-Output-Filename | foo | header | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + Then there should be the following file(s) in the webhook request: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + + Scenario: POST /forms/chromium/convert/html (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + Then the response status code should be 401 + + Scenario: POST /foo/forms/chromium/convert/html (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "POST" request to Gotenberg at the "/foo/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" diff --git a/test/integration/features/chromium_convert_markdown.feature b/test/integration/features/chromium_convert_markdown.feature new file mode 100644 index 000000000..8afa1c797 --- /dev/null +++ b/test/integration/features/chromium_convert_markdown.feature @@ -0,0 +1,1132 @@ +# TODO: +# 1. JavaScript disabled on some feature. + +@chromium +@chromium-convert-markdown +Feature: /forms/chromium/convert/markdown + + Scenario: POST /forms/chromium/convert/markdown (Default) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + + Scenario: POST /forms/chromium/convert/markdown (Single Page) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/pages-12-markdown/index.html | file | + | files | testdata/pages-12-markdown/page_1.md | file | + | files | testdata/pages-12-markdown/page_2.md | file | + | files | testdata/pages-12-markdown/page_3.md | file | + | files | testdata/pages-12-markdown/page_4.md | file | + | files | testdata/pages-12-markdown/page_5.md | file | + | files | testdata/pages-12-markdown/page_6.md | file | + | files | testdata/pages-12-markdown/page_7.md | file | + | files | testdata/pages-12-markdown/page_8.md | file | + | files | testdata/pages-12-markdown/page_9.md | file | + | files | testdata/pages-12-markdown/page_10.md | file | + | files | testdata/pages-12-markdown/page_11.md | file | + | files | testdata/pages-12-markdown/page_12.md | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 12 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should have the following content at page 12: + """ + Page 12 + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/pages-12-markdown/index.html | file | + | files | testdata/pages-12-markdown/page_1.md | file | + | files | testdata/pages-12-markdown/page_2.md | file | + | files | testdata/pages-12-markdown/page_3.md | file | + | files | testdata/pages-12-markdown/page_4.md | file | + | files | testdata/pages-12-markdown/page_5.md | file | + | files | testdata/pages-12-markdown/page_6.md | file | + | files | testdata/pages-12-markdown/page_7.md | file | + | files | testdata/pages-12-markdown/page_8.md | file | + | files | testdata/pages-12-markdown/page_9.md | file | + | files | testdata/pages-12-markdown/page_10.md | file | + | files | testdata/pages-12-markdown/page_11.md | file | + | files | testdata/pages-12-markdown/page_12.md | file | + | singlePage | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should NOT have the following content at page 1: + # page-break-after: always; tells the browser's print engine to force a page break after each element, + # even when calculating a large enough paper height, Chromium's PDF rendering will still honor those page break + # directives. + """ + Page 12 + """ + + Scenario: POST /forms/chromium/convert/markdown (Landscape) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should NOT be set to landscape orientation + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | landscape | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should be set to landscape orientation + + Scenario: POST /forms/chromium/convert/markdown (Native Page Ranges) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/pages-12-markdown/index.html | file | + | files | testdata/pages-12-markdown/page_1.md | file | + | files | testdata/pages-12-markdown/page_2.md | file | + | files | testdata/pages-12-markdown/page_3.md | file | + | files | testdata/pages-12-markdown/page_4.md | file | + | files | testdata/pages-12-markdown/page_5.md | file | + | files | testdata/pages-12-markdown/page_6.md | file | + | files | testdata/pages-12-markdown/page_7.md | file | + | files | testdata/pages-12-markdown/page_8.md | file | + | files | testdata/pages-12-markdown/page_9.md | file | + | files | testdata/pages-12-markdown/page_10.md | file | + | files | testdata/pages-12-markdown/page_11.md | file | + | files | testdata/pages-12-markdown/page_12.md | file | + | nativePageRanges | 2-3 | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + + Scenario: POST /forms/chromium/convert/markdown (Header & Footer) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/pages-12-markdown/index.html | file | + | files | testdata/pages-12-markdown/page_1.md | file | + | files | testdata/pages-12-markdown/page_2.md | file | + | files | testdata/pages-12-markdown/page_3.md | file | + | files | testdata/pages-12-markdown/page_4.md | file | + | files | testdata/pages-12-markdown/page_5.md | file | + | files | testdata/pages-12-markdown/page_6.md | file | + | files | testdata/pages-12-markdown/page_7.md | file | + | files | testdata/pages-12-markdown/page_8.md | file | + | files | testdata/pages-12-markdown/page_9.md | file | + | files | testdata/pages-12-markdown/page_10.md | file | + | files | testdata/pages-12-markdown/page_11.md | file | + | files | testdata/pages-12-markdown/page_12.md | file | + | files | testdata/header-footer-html/header.html | file | + | files | testdata/header-footer-html/footer.html | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 12 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Pages 12 + """ + Then the "foo.pdf" PDF should have the following content at page 1: + """ + 1 of 12 + """ + Then the "foo.pdf" PDF should have the following content at page 12: + """ + Pages 12 + """ + Then the "foo.pdf" PDF should have the following content at page 12: + """ + 12 of 12 + """ + + Scenario: POST /forms/chromium/convert/markdown (Wait Delay) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + | waitDelay | 2.5s | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. + """ + + Scenario: POST /forms/chromium/convert/markdown (Wait For Expression) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + | waitForExpression | window.globalVar === 'ready' | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. + """ + + Scenario: POST /forms/chromium/convert/markdown (Emulated Media Type) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Emulated media type is 'print'. + """ + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Emulated media type is 'screen'. + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + | emulatedMediaType | print | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Emulated media type is 'print'. + """ + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Emulated media type is 'screen'. + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + | emulatedMediaType | screen | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Emulated media type is 'screen'. + """ + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Emulated media type is 'print'. + """ + + Scenario: POST /forms/chromium/convert/markdown (Default Allow / Deny Lists) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the Gotenberg container should log the following entries: + | 'file:///etc/passwd' matches the expression from the denied list | + + Scenario: POST /forms/chromium/convert/markdown (Main URL does NOT match allowed list) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + Then the response status code should be 403 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Forbidden + """ + + Scenario: POST /forms/chromium/convert/markdown (Main URL does match denied list) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | | + | CHROMIUM_DENY_LIST | ^file:///tmp.* | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + Then the response status code should be 403 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Forbidden + """ + + Scenario: POST /forms/chromium/convert/markdown (Request does not match the allowed list) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | ^file:///tmp.* | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the Gotenberg container should log the following entries: + | 'file:///etc/passwd' does not match the expression from the allowed list | + + Scenario: POST /forms/chromium/convert/markdown (JavaScript Enabled) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + JavaScript is enabled. + """ + + Scenario: POST /forms/chromium/convert/markdown (JavaScript Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_DISABLE_JAVASCRIPT | true | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + JavaScript is enabled. + """ + + Scenario: POST /forms/chromium/convert/markdown (Fail On Resource HTTP Status Codes) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + | failOnResourceHttpStatusCodes | [499,599] | field | + Then the response status code should be 409 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid HTTP status code from resources: + https://gethttpstatus.com/400 - 400: Bad Request + """ + + Scenario: POST /forms/chromium/convert/markdown (Fail On Resource Loading Failed) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + | failOnResourceLoadingFailed | true | field | + Then the response status code should be 409 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should contain string: + """ + Chromium failed to load resources + """ + Then the response body should contain string: + """ + resource Stylesheet: net::ERR_CONNECTION_REFUSED + """ + Then the response body should contain string: + """ + resource Stylesheet: net::ERR_FILE_NOT_FOUND + """ + + Scenario: POST /forms/chromium/convert/markdown (Fail On Console Exceptions) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/feature-rich-markdown/index.html | file | + | files | testdata/feature-rich-markdown/table.md | file | + | failOnConsoleExceptions | true | field | + Then the response status code should be 409 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should contain string: + """ + Chromium console exceptions + """ + Then the response body should contain string: + """ + Error: Exception 1 + """ + Then the response body should contain string: + """ + Error: Exception 2 + """ + + Scenario: POST /forms/chromium/convert/markdown (Bad Request) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/pages-3-markdown/index.html | file | + | files | testdata/pages-3-markdown/page_1.md | file | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Markdown file(s) not found: 'page_2.md'; 'page_3.md' + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | singlePage | foo | field | + | paperWidth | foo | field | + | paperHeight | foo | field | + | marginTop | foo | field | + | marginBottom | foo | field | + | marginLeft | foo | field | + | marginRight | foo | field | + | preferCssPageSize | foo | field | + | generateDocumentOutline | foo | field | + | generateTaggedPdf | foo | field | + | printBackground | foo | field | + | omitBackground | foo | field | + | landscape | foo | field | + | scale | foo | field | + | waitDelay | foo | field | + | emulatedMediaType | foo | field | + | failOnHttpStatusCodes | foo | field | + | failOnResourceHttpStatusCodes | foo | field | + | failOnResourceLoadingFailed | foo | field | + | failOnConsoleExceptions | foo | field | + | skipNetworkIdleEvent | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'skipNetworkIdleEvent' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnResourceHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceLoadingFailed' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnConsoleExceptions' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'waitDelay' is invalid (got 'foo', resulting to time: invalid duration "foo"); form field 'emulatedMediaType' is invalid (got 'foo', resulting to wrong value, expected either 'screen', 'print' or empty); form field 'omitBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'printBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'scale' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'singlePage' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'paperWidth' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'paperHeight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginTop' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginBottom' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginLeft' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginRight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'preferCssPageSize' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateDocumentOutline' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateTaggedPdf' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form file 'index.html' is required; no form file found for extensions: [.md] + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | omitBackground | true | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + omitBackground requires printBackground set to true + """ + # Does not seems to happen on amd architectures anymore since Chromium 137. + # See: https://github.com/gotenberg/gotenberg/actions/runs/15384321883/job/43280184372. + # When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + # | files | testdata/page-1-markdown/index.html | file | + # | files | testdata/page-1-markdown/page_1.md | file | + # | paperWidth | 0 | field | + # | paperHeight | 0 | field | + # | marginTop | 1000000 | field | + # | marginBottom | 1000000 | field | + # | marginLeft | 1000000 | field | + # | marginRight | 1000000 | field | + # Then the response status code should be 400 + # Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + # Then the response body should match string: + # """ + # Chromium does not handle the provided settings; please check for aberrant form values + # """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | nativePageRanges | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Chromium does not handle the page ranges 'foo' (nativePageRanges) syntax + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | nativePageRanges | 2-3 | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + The page ranges '2-3' (nativePageRanges) exceeds the page count + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | waitForExpression | undefined | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + The expression 'undefined' (waitForExpression) returned an exception or undefined + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | cookies | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'cookies' is invalid (got 'foo', resulting to unmarshal cookies: invalid character 'o' in literal false (expecting 'a')) + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | cookies | [{"name":"yummy_cookie","value":"choco"}] | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'cookies' is invalid (got '[{"name":"yummy_cookie","value":"choco"}]', resulting to cookie 0 must have its name, value and domain set) + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | extraHttpHeaders | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'extraHttpHeaders' is invalid (got 'foo', resulting to unmarshal extraHttpHeaders: invalid character 'o' in literal false (expecting 'a')) + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | extraHttpHeaders | {"foo":"bar;scope;;"} | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope;;"}', resulting to invalid scope '' for header 'foo') + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | extraHttpHeaders | {"foo":"bar;scope=*."} | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`) + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | splitMode | foo | field | + | splitSpan | 2 | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages') + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | splitMode | intervals | field | + | splitSpan | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax) + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | splitMode | pages | field | + | splitSpan | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | pdfa | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | pdfua | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | metadata | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a')) + """ + + @split + Scenario: POST /forms/chromium/convert/markdown (Split Intervals) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/pages-3-markdown/index.html | file | + | files | testdata/pages-3-markdown/page_1.md | file | + | files | testdata/pages-3-markdown/page_2.md | file | + | files | testdata/pages-3-markdown/page_3.md | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | *_0.pdf | + | *_1.pdf | + Then the "*_0.pdf" PDF should have 2 page(s) + Then the "*_1.pdf" PDF should have 1 page(s) + Then the "*_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "*_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "*_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + # See https://github.com/gotenberg/gotenberg/issues/1130. + @split + @output-filename + Scenario: POST /forms/chromium/convert/markdown (Split Output Filename) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/pages-3-markdown/index.html | file | + | files | testdata/pages-3-markdown/page_1.md | file | + | files | testdata/pages-3-markdown/page_2.md | file | + | files | testdata/pages-3-markdown/page_3.md | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.zip | + | foo_0.pdf | + | foo_1.pdf | + Then the "foo_0.pdf" PDF should have 2 page(s) + Then the "foo_1.pdf" PDF should have 1 page(s) + Then the "foo_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "foo_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + @split + Scenario: POST /forms/chromium/convert/markdown (Split Pages) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/pages-3-markdown/index.html | file | + | files | testdata/pages-3-markdown/page_1.md | file | + | files | testdata/pages-3-markdown/page_2.md | file | + | files | testdata/pages-3-markdown/page_3.md | file | + | splitMode | pages | field | + | splitSpan | 2- | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | *_0.pdf | + | *_1.pdf | + Then the "*_0.pdf" PDF should have 1 page(s) + Then the "*_1.pdf" PDF should have 1 page(s) + Then the "*_0.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "*_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + @split + Scenario: POST /forms/chromium/convert/markdown (Split Pages & Unify) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/pages-3-markdown/index.html | file | + | files | testdata/pages-3-markdown/page_1.md | file | + | files | testdata/pages-3-markdown/page_2.md | file | + | files | testdata/pages-3-markdown/page_3.md | file | + | splitMode | pages | field | + | splitSpan | 2- | field | + | splitUnify | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + + @split + Scenario: POST /forms/chromium/convert/markdown (Split Many PDFs - Lot of Pages) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/pages-12-markdown/index.html | file | + | files | testdata/pages-12-markdown/page_1.md | file | + | files | testdata/pages-12-markdown/page_2.md | file | + | files | testdata/pages-12-markdown/page_3.md | file | + | files | testdata/pages-12-markdown/page_4.md | file | + | files | testdata/pages-12-markdown/page_5.md | file | + | files | testdata/pages-12-markdown/page_6.md | file | + | files | testdata/pages-12-markdown/page_7.md | file | + | files | testdata/pages-12-markdown/page_8.md | file | + | files | testdata/pages-12-markdown/page_9.md | file | + | files | testdata/pages-12-markdown/page_10.md | file | + | files | testdata/pages-12-markdown/page_11.md | file | + | files | testdata/pages-12-markdown/page_12.md | file | + | splitMode | intervals | field | + | splitSpan | 1 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 12 PDF(s) in the response + Then there should be the following file(s) in the response: + | *_0.pdf | + | *_1.pdf | + | *_2.pdf | + | *_3.pdf | + | *_4.pdf | + | *_5.pdf | + | *_6.pdf | + | *_7.pdf | + | *_8.pdf | + | *_9.pdf | + | *_10.pdf | + | *_11.pdf | + Then the "*_0.pdf" PDF should have 1 page(s) + Then the "*_11.pdf" PDF should have 1 page(s) + Then the "*_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "*_11.pdf" PDF should have the following content at page 1: + """ + Page 12 + """ + + @convert + Scenario: POST /forms/chromium/convert/markdown (PDF/A-1b & PDF/UA-1) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + @convert + @split + Scenario: POST /forms/chromium/convert/markdown (Split & PDF/A-1b & PDF/UA-1) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/pages-3-markdown/index.html | file | + | files | testdata/pages-3-markdown/page_1.md | file | + | files | testdata/pages-3-markdown/page_2.md | file | + | files | testdata/pages-3-markdown/page_3.md | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | *_0.pdf | + | *_1.pdf | + Then the "*_0.pdf" PDF should have 2 page(s) + Then the "*_1.pdf" PDF should have 1 page(s) + Then the "*_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "*_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "*_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + # See https://github.com/gotenberg/gotenberg/issues/1130. + @convert + @split + @output-filename + Scenario: POST /forms/chromium/convert/markdown (Split & PDF/A-1b & PDF/UA-1 & Output Filename) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/pages-3-markdown/index.html | file | + | files | testdata/pages-3-markdown/page_1.md | file | + | files | testdata/pages-3-markdown/page_2.md | file | + | files | testdata/pages-3-markdown/page_3.md | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.zip | + | foo_0.pdf | + | foo_1.pdf | + Then the "foo_0.pdf" PDF should have 2 page(s) + Then the "foo_1.pdf" PDF should have 1 page(s) + Then the "foo_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "foo_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + @metadata + Scenario: POST /forms/chromium/convert/markdown (Metadata) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "foo.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + @flatten + Scenario: POST /forms/chromium/convert/markdown (Flatten) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | flatten | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be flatten + + @encrypt + Scenario: POST /forms/chromium/convert/markdown (Encrypt - user password only) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | userPassword | foo | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + @encrypt + Scenario: POST /forms/chromium/convert/markdown (Encrypt - both user and owner passwords) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | userPassword | foo | field | + | ownerPassword | bar | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + @embed + Scenario: POST /forms/chromium/convert/markdown (Embeds) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should have the "embed_2.xml" file embedded + + # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. + @convert + @metadata + @flatten + @embed + Scenario: POST /forms/chromium/convert/markdown (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | flatten | true | field | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 9 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) + Then the response PDF(s) should be flatten + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should have the "embed_2.xml" file embedded + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "foo.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + Scenario: POST /forms/chromium/convert/markdown (Routes Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_DISABLE_ROUTES | true | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + Then the response status code should be 404 + + Scenario: POST /forms/chromium/convert/markdown (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | Gotenberg-Trace | forms_chromium_convert_html | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_html" + Then the Gotenberg container should log the following entries: + | "trace":"forms_chromium_convert_html" | + + @download-from + Scenario: POST /forms/chromium/convert/markdown (Download From) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page-1-markdown/index.html","extraHttpHeaders":{"X-Foo":"bar"}}] | field | + | files | testdata/page-1-markdown/page_1.md | file | + Then the response status code should be 200 + Then the file request header "X-Foo" should be "bar" + Then the response header "Content-Type" should be "application/pdf" + + @webhook + Scenario: POST /forms/chromium/convert/markdown (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | Gotenberg-Output-Filename | foo | header | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + Then there should be the following file(s) in the webhook request: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + + Scenario: POST /forms/chromium/convert/markdown (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + Then the response status code should be 401 + + Scenario: POST /foo/forms/chromium/convert/markdown (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "POST" request to Gotenberg at the "/foo/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature new file mode 100644 index 000000000..d352ccc23 --- /dev/null +++ b/test/integration/features/chromium_convert_url.feature @@ -0,0 +1,1093 @@ +# TODO: +# 1. JavaScript disabled on some feature. + +@chromium +@chromium-convert-url +Feature: /forms/chromium/convert/url + + Scenario: POST /forms/chromium/convert/url (Default) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + + Scenario: POST /forms/chromium/convert/url (Single Page) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 12 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should have the following content at page 12: + """ + Page 12 + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field | + | singlePage | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should NOT have the following content at page 1: + # page-break-after: always; tells the browser's print engine to force a page break after each element, + # even when calculating a large enough paper height, Chromium's PDF rendering will still honor those page break + # directives. + """ + Page 12 + """ + + Scenario: POST /forms/chromium/convert/url (Landscape) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should NOT be set to landscape orientation + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | landscape | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should be set to landscape orientation + + Scenario: POST /forms/chromium/convert/url (Native Page Ranges) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field | + | nativePageRanges | 2-3 | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + + Scenario: POST /forms/chromium/convert/url (Header & Footer) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field | + | files | testdata/header-footer-html/header.html | file | + | files | testdata/header-footer-html/footer.html | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 12 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Pages 12 + """ + Then the "foo.pdf" PDF should have the following content at page 1: + """ + 1 of 12 + """ + Then the "foo.pdf" PDF should have the following content at page 12: + """ + Pages 12 + """ + Then the "foo.pdf" PDF should have the following content at page 12: + """ + 12 of 12 + """ + + Scenario: POST /forms/chromium/convert/url (Custom HTTP Headers) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | userAgent | Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) | field | + | extraHttpHeaders | {"X-Header":"foo","X-Scoped-Header-1":"bar;scope=https?:\\/\\/([a-zA-Z0-9-]+\\\\.)*domain\\\\.com\\/.*","X-Scoped-Header-2":"baz;scope=https?:\\/\\/([a-zA-Z0-9-]+\\\\.)*docker\\\\.internal:(\\\\d+)\\/.*"} | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the server request header "User-Agent" should be "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)" + Then the server request header "X-Header" should be "foo" + Then the server request header "X-Scoped-Header-1" should be "" + Then the server request header "X-Scoped-Header-2" should be "baz" + + Scenario: POST /forms/chromium/convert/url (Cookies) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | cookies | [{"name":"cookie_1","value":"foo","domain":"host.docker.internal:%d"},{"name":"cookie_2","value":"bar","domain":"domain.com"}] | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the server request cookie "cookie_1" should be "foo" + Then the server request cookie "cookie_2" should be "" + + # See https://github.com/gotenberg/gotenberg/issues/1130. + Scenario: POST /forms/chromium/convert/url (case-insensitive sameSite) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | cookies | [{"name":"cookie_1","value":"foo","domain":"host.docker.internal:%d"},{"name":"cookie_2","value":"bar","domain":"domain.com","sameSite":"lax"}] | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the server request cookie "cookie_1" should be "foo" + Then the server request cookie "cookie_2" should be "" + + Scenario: POST /forms/chromium/convert/url (Wait Delay) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | waitDelay | 2.5s | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. + """ + + Scenario: POST /forms/chromium/convert/url (Wait For Expression) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | waitForExpression | window.globalVar === 'ready' | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. + """ + + Scenario: POST /forms/chromium/convert/url (Emulated Media Type) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Emulated media type is 'print'. + """ + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Emulated media type is 'screen'. + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | emulatedMediaType | print | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Emulated media type is 'print'. + """ + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Emulated media type is 'screen'. + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | emulatedMediaType | screen | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Emulated media type is 'screen'. + """ + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Emulated media type is 'print'. + """ + + Scenario: POST /forms/chromium/convert/url (Default Allow / Deny Lists) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the Gotenberg container should NOT log the following entries: + # Modern browsers block file URIs from being loaded into iframes when the parent page is served over HTTP/HTTPS. + | 'file:///etc/passwd' matches the expression from the denied list | + + Scenario: POST /forms/chromium/convert/url (Main URL does NOT match allowed list) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* | + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + Then the response status code should be 403 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Forbidden + """ + + Scenario: POST /forms/chromium/convert/url (Main URL does match denied list) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | | + | CHROMIUM_DENY_LIST | ^http.* | + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + Then the response status code should be 403 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Forbidden + """ + + Scenario: POST /forms/chromium/convert/url (Request does not match the allowed list) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | ^.* | + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the Gotenberg container should NOT log the following entries: + # Modern browsers block file URIs from being loaded into iframes when the parent page is served over HTTP/HTTPS. + | 'file:///etc/passwd' does not match the expression from the allowed list | + + Scenario: POST /forms/chromium/convert/url (JavaScript Enabled) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + JavaScript is enabled. + """ + + Scenario: POST /forms/chromium/convert/url (JavaScript Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_DISABLE_JAVASCRIPT | true | + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + JavaScript is enabled. + """ + + Scenario: POST /forms/chromium/convert/url (Fail On Resource HTTP Status Codes) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | failOnResourceHttpStatusCodes | [499,599] | field | + Then the response status code should be 409 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should contain string: + """ + Invalid HTTP status code from resources: + """ + Then the response body should contain string: + """ + /favicon.ico - 404: Not Found + """ + + Scenario: POST /forms/chromium/convert/url (Fail On Resource Loading Failed) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | failOnResourceLoadingFailed | true | field | + Then the response status code should be 409 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should contain string: + """ + Chromium failed to load resources + """ + Then the response body should contain string: + """ + resource Stylesheet: net::ERR_CONNECTION_REFUSED + """ + + Scenario: POST /forms/chromium/convert/url (Fail On Console Exceptions) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | failOnConsoleExceptions | true | field | + Then the response status code should be 409 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should contain string: + """ + Chromium console exceptions + """ + Then the response body should contain string: + """ + Error: Exception 1 + """ + Then the response body should contain string: + """ + Error: Exception 2 + """ + + Scenario: POST /forms/chromium/convert/url (Bad Request) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | singlePage | foo | field | + | paperWidth | foo | field | + | paperHeight | foo | field | + | marginTop | foo | field | + | marginBottom | foo | field | + | marginLeft | foo | field | + | marginRight | foo | field | + | preferCssPageSize | foo | field | + | generateDocumentOutline | foo | field | + | generateTaggedPdf | foo | field | + | printBackground | foo | field | + | omitBackground | foo | field | + | landscape | foo | field | + | scale | foo | field | + | waitDelay | foo | field | + | emulatedMediaType | foo | field | + | failOnHttpStatusCodes | foo | field | + | failOnResourceHttpStatusCodes | foo | field | + | failOnResourceLoadingFailed | foo | field | + | failOnConsoleExceptions | foo | field | + | skipNetworkIdleEvent | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'skipNetworkIdleEvent' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceHttpStatusCodes' is invalid (got 'foo', resulting to unmarshal failOnResourceHttpStatusCodes: invalid character 'o' in literal false (expecting 'a')); form field 'failOnResourceLoadingFailed' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'failOnConsoleExceptions' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'waitDelay' is invalid (got 'foo', resulting to time: invalid duration "foo"); form field 'emulatedMediaType' is invalid (got 'foo', resulting to wrong value, expected either 'screen', 'print' or empty); form field 'omitBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'printBackground' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'scale' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'singlePage' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'paperWidth' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'paperHeight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginTop' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginBottom' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginLeft' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'marginRight' is invalid (got 'foo', resulting to strconv.ParseFloat: parsing "foo": invalid syntax); form field 'preferCssPageSize' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateDocumentOutline' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'generateTaggedPdf' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'url' is required + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | omitBackground | true | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + omitBackground requires printBackground set to true + """ + # Does not seems to happen on amd architectures anymore since Chromium 137. + # See: https://github.com/gotenberg/gotenberg/actions/runs/15384321883/job/43280184372. + # Given I have a static server + # When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + # | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + # | paperWidth | 0 | field | + # | paperHeight | 0 | field | + # | marginTop | 1000000 | field | + # | marginBottom | 1000000 | field | + # | marginLeft | 1000000 | field | + # | marginRight | 1000000 | field | + # Then the response status code should be 400 + # Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + # Then the response body should match string: + # """ + # Chromium does not handle the provided settings; please check for aberrant form values + # """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | nativePageRanges | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Chromium does not handle the page ranges 'foo' (nativePageRanges) syntax + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | nativePageRanges | 2-3 | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + The page ranges '2-3' (nativePageRanges) exceeds the page count + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | waitForExpression | undefined | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + The expression 'undefined' (waitForExpression) returned an exception or undefined + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | cookies | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'cookies' is invalid (got 'foo', resulting to unmarshal cookies: invalid character 'o' in literal false (expecting 'a')) + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | cookies | [{"name":"yummy_cookie","value":"choco"}] | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'cookies' is invalid (got '[{"name":"yummy_cookie","value":"choco"}]', resulting to cookie 0 must have its name, value and domain set) + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | extraHttpHeaders | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'extraHttpHeaders' is invalid (got 'foo', resulting to unmarshal extraHttpHeaders: invalid character 'o' in literal false (expecting 'a')) + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | extraHttpHeaders | {"foo":"bar;scope;;"} | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope;;"}', resulting to invalid scope '' for header 'foo') + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | extraHttpHeaders | {"foo":"bar;scope=*."} | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`) + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | splitMode | foo | field | + | splitSpan | 2 | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages') + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | splitMode | intervals | field | + | splitSpan | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax) + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | splitMode | pages | field | + | splitSpan | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | pdfa | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | pdfua | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | metadata | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a')) + """ + + @split + Scenario: POST /forms/chromium/convert/url (Split Intervals) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field | + | splitMode | intervals | field | + | splitSpan | 2 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | *_0.pdf | + | *_1.pdf | + Then the "*_0.pdf" PDF should have 2 page(s) + Then the "*_1.pdf" PDF should have 1 page(s) + Then the "*_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "*_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "*_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + # See https://github.com/gotenberg/gotenberg/issues/1130. + @split + @output-filename + Scenario: POST /forms/chromium/convert/url (Split Output Filename) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.zip | + | foo_0.pdf | + | foo_1.pdf | + Then the "foo_0.pdf" PDF should have 2 page(s) + Then the "foo_1.pdf" PDF should have 1 page(s) + Then the "foo_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "foo_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + @split + Scenario: POST /forms/chromium/convert/url (Split Pages) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field | + | splitMode | pages | field | + | splitSpan | 2- | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | *_0.pdf | + | *_1.pdf | + Then the "*_0.pdf" PDF should have 1 page(s) + Then the "*_1.pdf" PDF should have 1 page(s) + Then the "*_0.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "*_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + @split + Scenario: POST /forms/chromium/convert/url (Split Pages & Unify) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field | + | splitMode | pages | field | + | splitSpan | 2- | field | + | splitUnify | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + + @split + Scenario: POST /forms/chromium/convert/url (Split Many PDFs - Lot of Pages) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field | + | splitMode | intervals | field | + | splitSpan | 1 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 12 PDF(s) in the response + Then there should be the following file(s) in the response: + | *_0.pdf | + | *_1.pdf | + | *_2.pdf | + | *_3.pdf | + | *_4.pdf | + | *_5.pdf | + | *_6.pdf | + | *_7.pdf | + | *_8.pdf | + | *_9.pdf | + | *_10.pdf | + | *_11.pdf | + Then the "*_0.pdf" PDF should have 1 page(s) + Then the "*_11.pdf" PDF should have 1 page(s) + Then the "*_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "*_11.pdf" PDF should have the following content at page 1: + """ + Page 12 + """ + + @convert + Scenario: POST /forms/chromium/convert/url (PDF/A-1b & PDF/UA-1) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + Scenario: POST /forms/chromium/convert/url (Split & PDF/A-1b & PDF/UA-1) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | *_0.pdf | + | *_1.pdf | + Then the "*_0.pdf" PDF should have 2 page(s) + Then the "*_1.pdf" PDF should have 1 page(s) + Then the "*_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "*_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "*_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + # See https://github.com/gotenberg/gotenberg/issues/1130. + @convert + @split + @output-filename + Scenario: POST /forms/chromium/convert/url (Split & PDF/A-1b & PDF/UA-1 & Output Filename) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/pages-3-html/index.html | field | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.zip | + | foo_0.pdf | + | foo_1.pdf | + Then the "foo_0.pdf" PDF should have 2 page(s) + Then the "foo_1.pdf" PDF should have 1 page(s) + Then the "foo_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "foo_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + @metadata + Scenario: POST /forms/chromium/convert/url (Metadata) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "foo.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + @flatten + Scenario: POST /forms/chromium/convert/url (Flatten) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | flatten | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be flatten + + @encrypt + Scenario: POST /forms/chromium/convert/url (Encrypt - user password only) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | userPassword | foo | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + @encrypt + Scenario: POST /forms/chromium/convert/url (Encrypt - both user and owner passwords) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | userPassword | foo | field | + | ownerPassword | bar | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + @embed + Scenario: POST /foo/forms/chromium/convert/url (Embeds) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the webhook request: + | foo.pdf | + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should have the "embed_2.xml" file embedded + + # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. + @convert + @metadata + @flatten + @embed + Scenario: POST /forms/chromium/convert/url (PDF/A-1b & PDF/UA-1 & Metadata & Flatten) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | flatten | true | field | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 9 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) + Then the response PDF(s) should be flatten + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should have the "embed_2.xml" file embedded + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "foo.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + Scenario: POST /forms/chromium/convert/url (Routes Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_DISABLE_ROUTES | true | + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + Then the response status code should be 404 + + Scenario: POST /forms/chromium/convert/url (Gotenberg Trace) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | Gotenberg-Trace | forms_chromium_convert_url | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_url" + Then the Gotenberg container should log the following entries: + | "trace":"forms_chromium_convert_url" | + + @webhook + Scenario: POST /forms/chromium/convert/url (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | Gotenberg-Output-Filename | foo | header | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + Then there should be the following file(s) in the webhook request: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + + Scenario: POST /forms/chromium/convert/url (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + Then the response status code should be 401 + + Scenario: POST /foo/forms/chromium/convert/url (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + Given I have a static server + When I make a "POST" request to Gotenberg at the "/foo/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" diff --git a/test/integration/features/debug.feature b/test/integration/features/debug.feature new file mode 100644 index 000000000..b3f966044 --- /dev/null +++ b/test/integration/features/debug.feature @@ -0,0 +1,167 @@ +@debug +Feature: /debug + + Scenario: GET /debug (Disabled) + Given I have a default Gotenberg container + When I make a "GET" request to Gotenberg at the "/debug" endpoint + Then the response status code should be 404 + + Scenario: GET /debug (Enabled) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + When I make a "GET" request to Gotenberg at the "/debug" endpoint + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "version": "{version}", + "architecture": "ignore", + "modules": [ + "api", + "chromium", + "exiftool", + "libreoffice", + "libreoffice-api", + "libreoffice-pdfengine", + "logging", + "pdfcpu", + "pdfengines", + "pdftk", + "prometheus", + "qpdf", + "webhook" + ], + "modules_additional_data": { + "chromium": { + "version": "ignore" + }, + "exiftool": { + "version": "ignore" + }, + "libreoffice-api": { + "version": "ignore" + }, + "pdfcpu": { + "version": "ignore" + }, + "pdftk": { + "version": "ignore" + }, + "qpdf": { + "version": "ignore" + } + }, + "flags": { + "api-bind-ip": "", + "api-body-limit": "", + "api-disable-download-from": "false", + "api-disable-health-check-logging": "false", + "api-download-from-allow-list": "", + "api-download-from-deny-list": "", + "api-download-from-max-retry": "4", + "api-enable-basic-auth": "false", + "api-enable-debug-route": "true", + "api-port": "3000", + "api-port-from-env": "", + "api-root-path": "/", + "api-start-timeout": "30s", + "api-timeout": "30s", + "api-tls-cert-file": "", + "api-tls-key-file": "", + "api-trace-header": "Gotenberg-Trace", + "chromium-allow-file-access-from-files": "false", + "chromium-allow-insecure-localhost": "false", + "chromium-allow-list": "", + "chromium-auto-start": "false", + "chromium-clear-cache": "false", + "chromium-clear-cookies": "false", + "chromium-deny-list": "^file:(?!//\\/tmp/).*", + "chromium-disable-javascript": "false", + "chromium-disable-routes": "false", + "chromium-disable-web-security": "false", + "chromium-host-resolver-rules": "", + "chromium-ignore-certificate-errors": "false", + "chromium-incognito": "false", + "chromium-max-queue-size": "0", + "chromium-proxy-server": "", + "chromium-restart-after": "10", + "chromium-start-timeout": "20s", + "gotenberg-build-debug-data": "true", + "gotenberg-graceful-shutdown-duration": "30s", + "libreoffice-auto-start": "false", + "libreoffice-disable-routes": "false", + "libreoffice-max-queue-size": "0", + "libreoffice-restart-after": "10", + "libreoffice-start-timeout": "20s", + "log-fields-prefix": "", + "log-format": "auto", + "log-level": "info", + "pdfengines-convert-engines": "[libreoffice-pdfengine]", + "pdfengines-disable-routes": "false", + "pdfengines-engines": "[]", + "pdfengines-flatten-engines": "[qpdf]", + "pdfengines-merge-engines": "[qpdf,pdfcpu,pdftk]", + "pdfengines-read-metadata-engines": "[exiftool]", + "pdfengines-split-engines": "[pdfcpu,qpdf,pdftk]", + "pdfengines-write-metadata-engines": "[exiftool]", + "prometheus-collect-interval": "1s", + "prometheus-disable-collect": "false", + "prometheus-disable-route-logging": "false", + "prometheus-namespace": "gotenberg", + "webhook-allow-list": "", + "webhook-client-timeout": "30s", + "webhook-deny-list": "", + "webhook-disable": "false", + "webhook-error-allow-list": "", + "webhook-error-deny-list": "", + "webhook-max-retry": "4", + "webhook-retry-max-wait": "30s", + "webhook-retry-min-wait": "1s" + } + } + """ + + Scenario: GET /debug (No Debug Data) + Given I have a Gotenberg container with the following environment variable(s): + | GOTENBERG_BUILD_DEBUG_DATA | false | + | API_ENABLE_DEBUG_ROUTE | true | + When I make a "GET" request to Gotenberg at the "/debug" endpoint + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "version": "", + "architecture": "", + "modules": null, + "modules_additional_data": null, + "flags": null + } + """ + + Scenario: GET /debug (Gotenberg Trace) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + When I make a "GET" request to Gotenberg at the "/debug" endpoint with the following header(s): + | Gotenberg-Trace | debug | + Then the response status code should be 200 + Then the response header "Gotenberg-Trace" should be "debug" + Then the Gotenberg container should log the following entries: + | "trace":"debug" | + + Scenario: GET /debug (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "GET" request to Gotenberg at the "/debug" endpoint + Then the response status code should be 401 + + Scenario: GET /foo/debug (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "GET" request to Gotenberg at the "/foo/debug" endpoint + Then the response status code should be 200 diff --git a/test/integration/features/health.feature b/test/integration/features/health.feature new file mode 100644 index 000000000..81ae4b759 --- /dev/null +++ b/test/integration/features/health.feature @@ -0,0 +1,108 @@ +# TODO: +# 1. Check if down for each module. +# 2. Restarting modules do not make health check fail. + +@health +Feature: /health + + Scenario: GET /health + Given I have a default Gotenberg container + When I make a "GET" request to Gotenberg at the "/health" endpoint + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json; charset=utf-8" + Then the response body should match JSON: + """ + { + "status": "up", + "details": { + "chromium": { + "status": "up", + "timestamp": "ignore" + }, + "libreoffice": { + "status": "up", + "timestamp": "ignore" + } + } + } + """ + Then the Gotenberg container should log the following entries: + | "path":"/health" | + + Scenario: GET /health (No Logging) + Given I have a Gotenberg container with the following environment variable(s): + | API_DISABLE_HEALTH_CHECK_LOGGING | true | + When I make a "GET" request to Gotenberg at the "/health" endpoint + Then the response status code should be 200 + Then the Gotenberg container should NOT log the following entries: + | "path":"/health" | + + Scenario: GET /health (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "GET" request to Gotenberg at the "/health" endpoint with the following header(s): + | Gotenberg-Trace | get_health | + Then the response status code should be 200 + Then the response header "Gotenberg-Trace" should be "get_health" + Then the Gotenberg container should log the following entries: + | "trace":"get_health" | + + Scenario: GET /health (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "GET" request to Gotenberg at the "/health" endpoint + Then the response status code should be 200 + + Scenario: GET /foo/health (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ROOT_PATH | /foo/ | + When I make a "GET" request to Gotenberg at the "/foo/health" endpoint + Then the response status code should be 200 + + Scenario: HEAD /health + Given I have a default Gotenberg container + When I make a "HEAD" request to Gotenberg at the "/health" endpoint + Then the response status code should be 200 + Then the response body should match string: + """ + + """ + Then the Gotenberg container should log the following entries: + | "path":"/health" | + + Scenario: HEAD /health (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "HEAD" request to Gotenberg at the "/health" endpoint with the following header(s): + | Gotenberg-Trace | head_health | + Then the response status code should be 200 + Then the response header "Gotenberg-Trace" should be "head_health" + Then the Gotenberg container should log the following entries: + | "trace":"head_health" | + + Scenario: HEAD /health (No Logging) + Given I have a Gotenberg container with the following environment variable(s): + | API_DISABLE_HEALTH_CHECK_LOGGING | true | + When I make a "HEAD" request to Gotenberg at the "/health" endpoint + Then the response status code should be 200 + Then the Gotenberg container should NOT log the following entries: + | "path":"/health" | + + Scenario: HEAD /health (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "HEAD" request to Gotenberg at the "/health" endpoint + Then the response status code should be 200 + + Scenario: HEAD /foo/health (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ROOT_PATH | /foo/ | + When I make a "HEAD" request to Gotenberg at the "/foo/health" endpoint + Then the response status code should be 200 + + +# TODO: +# 1. Check if down for each module. +# 2. Restarting modules do not make health check fail. \ No newline at end of file diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature new file mode 100644 index 000000000..13279a71b --- /dev/null +++ b/test/integration/features/libreoffice_convert.feature @@ -0,0 +1,697 @@ +@libreoffice +@libreoffice-convert +Feature: /forms/libreoffice/convert + + Scenario: POST /forms/libreoffice/convert (Single Document) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + + Scenario: POST /forms/libreoffice/convert (Many Documents) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | files | testdata/page_2.docx | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.zip | + | page_1.docx.pdf | + | page_2.docx.pdf | + Then the "page_1.docx.pdf" PDF should have 1 page(s) + Then the "page_2.docx.pdf" PDF should have 1 page(s) + Then the "page_1.docx.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "page_2.docx.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + + # See: + # https://github.com/gotenberg/gotenberg/issues/104 + # https://github.com/gotenberg/gotenberg/issues/730 + Scenario: POST /forms/libreoffice/convert (Non-basic Latin Characters) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/Special_Chars_รŸ.docx | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + + Scenario: POST /forms/libreoffice/convert (Protected) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/protected_page_1.docx | file | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain. + """ + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/protected_page_1.docx | file | + | password | foo | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + Scenario: POST /forms/libreoffice/convert (Landscape) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | Gotenberg-Output-Filename | foo | header | + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should NOT be set to landscape orientation + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | landscape | true | field | + | Gotenberg-Output-Filename | foo | header | + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should be set to landscape orientation + + Scenario: POST /forms/libreoffice/convert (Native Page Ranges - Single Document) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/pages_3.docx | file | + | nativePageRanges | 2-3 | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + + Scenario: POST /forms/libreoffice/convert (Native Page Ranges - Many Documents) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/pages_3.docx | file | + | files | testdata/pages_12.docx | file | + | nativePageRanges | 2-3 | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.zip | + | pages_3.docx.pdf | + | pages_12.docx.pdf | + Then the "pages_3.docx.pdf" PDF should have 2 page(s) + Then the "pages_12.docx.pdf" PDF should have 2 page(s) + Then the "pages_3.docx.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "pages_3.docx.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + Then the "pages_12.docx.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "pages_12.docx.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + + Scenario: POST /forms/libreoffice/convert (Bad Request) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | landscape | foo | field | + | exportFormFields | foo | field | + | allowDuplicateFieldNames | foo | field | + | exportBookmarks | foo | field | + | exportBookmarksToPdfDestination | foo | field | + | exportPlaceholders | foo | field | + | exportNotes | foo | field | + | exportNotesPages | foo | field | + | exportOnlyNotesPages | foo | field | + | exportNotesInMargin | foo | field | + | convertOooTargetToPdfTarget | foo | field | + | exportLinksRelativeFsys | foo | field | + | exportHiddenSlides | foo | field | + | skipEmptyPages | foo | field | + | addOriginalDocumentAsStream | foo | field | + | singlePageSheets | foo | field | + | losslessImageCompression | foo | field | + | quality | -1 | field | + | reduceImageResolution | foo | field | + | maxImageResolution | 10 | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: no form file found for extensions: [.123 .602 .abw .bib .bmp .cdr .cgm .cmx .csv .cwk .dbf .dif .doc .docm .docx .dot .dotm .dotx .dxf .emf .eps .epub .fodg .fodp .fods .fodt .fopd .gif .htm .html .hwp .jpeg .jpg .key .ltx .lwp .mcw .met .mml .mw .numbers .odd .odg .odm .odp .ods .odt .otg .oth .otp .ots .ott .pages .pbm .pcd .pct .pcx .pdb .pdf .pgm .png .pot .potm .potx .ppm .pps .ppt .pptm .pptx .psd .psw .pub .pwp .pxl .ras .rtf .sda .sdc .sdd .sdp .sdw .sgl .slk .smf .stc .std .sti .stw .svg .svm .swf .sxc .sxd .sxg .sxi .sxm .sxw .tga .tif .tiff .txt .uof .uop .uos .uot .vdx .vor .vsd .vsdm .vsdx .wb2 .wk1 .wks .wmf .wpd .wpg .wps .xbm .xhtml .xls .xlsb .xlsm .xlsx .xlt .xltm .xltx .xlw .xml .xpm .zabw]; form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportFormFields' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'allowDuplicateFieldNames' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportBookmarks' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportBookmarksToPdfDestination' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportPlaceholders' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportNotes' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportNotesPages' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportOnlyNotesPages' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportNotesInMargin' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'convertOooTargetToPdfTarget' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportLinksRelativeFsys' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'exportHiddenSlides' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'skipEmptyPages' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'addOriginalDocumentAsStream' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'singlePageSheets' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'losslessImageCompression' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'quality' is invalid (got '-1', resulting to value is inferior to 1); form field 'reduceImageResolution' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax); form field 'maxImageResolution' is invalid (got '10', resulting to value is not 75, 150, 300, 600 or 1200) + """ + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | nativePageRanges | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + LibreOffice failed to process a document: possible causes include malformed page ranges 'foo' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain. + """ + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | password | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + LibreOffice failed to process a document: possible causes include malformed page ranges '' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain. + """ + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/protected_page_1.docx | file | + | password | bar | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain. + """ + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | files | testdata/page_2.docx | file | + | merge | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'merge' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) + """ + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/pages_3.docx | file | + | splitMode | foo | field | + | splitSpan | 2 | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages') + """ + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/pages_3.docx | file | + | splitMode | intervals | field | + | splitSpan | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax) + """ + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/pages_3.docx | file | + | splitMode | pages | field | + | splitSpan | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues + """ + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/pages_3.docx | file | + | pdfa | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + A PDF format in '{PdfA:foo PdfUa:false}' is not supported + """ + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | pdfua | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) + """ + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | metadata | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a')) + """ + + @merge + Scenario: POST /forms/libreoffice/convert (Merge) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | files | testdata/page_2.docx | file | + | merge | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + + @merge + @split + Scenario: POST /forms/libreoffice/convert (Merge & Split) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | files | testdata/page_2.docx | file | + | merge | true | field | + | splitMode | intervals | field | + | splitSpan | 1 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | *_0.pdf | + | *_1.pdf | + Then the "*_0.pdf" PDF should have 1 page(s) + Then the "*_1.pdf" PDF should have 1 page(s) + Then the "*_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "*_1.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + + @split + Scenario: POST /forms/libreoffice/convert (Split Intervals) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/pages_3.docx | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3.docx_0.pdf | + | pages_3.docx_1.pdf | + Then the "pages_3.docx_0.pdf" PDF should have 2 page(s) + Then the "pages_3.docx_1.pdf" PDF should have 1 page(s) + Then the "pages_3.docx_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "pages_3.docx_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "pages_3.docx_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + @split + Scenario: POST /forms/libreoffice/convert (Split Pages) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/pages_3.docx | file | + | splitMode | pages | field | + | splitSpan | 2- | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3.docx_0.pdf | + | pages_3.docx_1.pdf | + Then the "pages_3.docx_0.pdf" PDF should have 1 page(s) + Then the "pages_3.docx_1.pdf" PDF should have 1 page(s) + Then the "pages_3.docx_0.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "pages_3.docx_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + @split + Scenario: POST /forms/libreoffice/convert (Split Pages & Unify) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/pages_3.docx | file | + | splitMode | pages | field | + | splitSpan | 2- | field | + | splitUnify | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3.docx.pdf | + Then the "pages_3.docx.pdf" PDF should have 2 page(s) + Then the "pages_3.docx.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "pages_3.docx.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + + @split + Scenario: POST /forms/libreoffice/convert (Split Many PDFs - Lot of Pages) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/pages_12.docx | file | + | files | testdata/pages_3.docx | file | + | splitMode | intervals | field | + | splitSpan | 1 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 15 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3.docx_0.pdf | + | pages_3.docx_1.pdf | + | pages_3.docx_2.pdf | + | pages_12.docx_0.pdf | + | pages_12.docx_1.pdf | + | pages_12.docx_2.pdf | + | pages_12.docx_3.pdf | + | pages_12.docx_4.pdf | + | pages_12.docx_5.pdf | + | pages_12.docx_6.pdf | + | pages_12.docx_7.pdf | + | pages_12.docx_8.pdf | + | pages_12.docx_9.pdf | + | pages_12.docx_10.pdf | + | pages_12.docx_11.pdf | + Then the "pages_3.docx_0.pdf" PDF should have 1 page(s) + Then the "pages_3.docx_2.pdf" PDF should have 1 page(s) + Then the "pages_12.docx_0.pdf" PDF should have 1 page(s) + Then the "pages_12.docx_11.pdf" PDF should have 1 page(s) + Then the "pages_3.docx_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "pages_3.docx_2.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + Then the "pages_12.docx_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "pages_12.docx_11.pdf" PDF should have the following content at page 1: + """ + Page 12 + """ + + @convert + Scenario: POST /forms/libreoffice/convert (PDF/A-1b & PDF/UA-1) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + @split + @convert + Scenario: POST /forms/libreoffice/convert (Split & PDF/A-1b & PDF/UA-1) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/pages_3.docx | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3.docx_0.pdf | + | pages_3.docx_1.pdf | + Then the "pages_3.docx_0.pdf" PDF should have 2 page(s) + Then the "pages_3.docx_1.pdf" PDF should have 1 page(s) + Then the "pages_3.docx_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "pages_3.docx_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "pages_3.docx_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + @metadata + Scenario: POST /forms/libreoffice/convert (Metadata) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "foo.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + @flatten + Scenario: POST /forms/libreoffice/convert (Flatten) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | flatten | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be flatten + + @encrypt + Scenario: POST /forms/libreoffice/convert (Encrypt - user password only) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | userPassword | foo | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + @encrypt + Scenario: POST /forms/libreoffice/convert (Encrypt - both user and owner passwords) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | userPassword | foo | field | + | ownerPassword | bar | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + @embed + Scenario: POST /forms/libreoffice/convert (Embeds) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should have the "embed_2.xml" file embedded + + # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. + @convert + @metadata + @flatten + @embed + Scenario: POST /forms/libreoffice/convert (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | flatten | true | field | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) + Then the response PDF(s) should be flatten + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should have the "embed_2.xml" file embedded + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "foo.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + Scenario: POST /forms/libreoffice/convert (Routes Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | LIBREOFFICE_DISABLE_ROUTES | true | + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + Then the response status code should be 404 + + Scenario: POST /forms/libreoffice/convert (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | Gotenberg-Trace | forms_libreoffice_convert | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then the response header "Gotenberg-Trace" should be "forms_libreoffice_convert" + Then the Gotenberg container should log the following entries: + | "trace":"forms_libreoffice_convert" | + + @download-from + Scenario: POST /forms/libreoffice/convert (Download From) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.docx","extraHttpHeaders":{"X-Foo":"bar"}}] | field | + Then the response status code should be 200 + Then the file request header "X-Foo" should be "bar" + Then the response header "Content-Type" should be "application/pdf" + + @webhook + Scenario: POST /forms/libreoffice/convert (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | Gotenberg-Output-Filename | foo | header | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + Then there should be the following file(s) in the webhook request: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + + Scenario: POST /forms/libreoffice/convert (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + Then the response status code should be 401 + + Scenario: POST /foo/forms/libreoffice/convert (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "POST" request to Gotenberg at the "/foo/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" diff --git a/test/integration/features/output_filename.feature b/test/integration/features/output_filename.feature new file mode 100644 index 000000000..631267866 --- /dev/null +++ b/test/integration/features/output_filename.feature @@ -0,0 +1,34 @@ +@output-filename +Feature: Output Filename + + Scenario: Default (Single Output File) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be the following file(s) in the response: + | foo.pdf | + + Scenario: Default (Many Output Files) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be the following file(s) in the response: + | foo.zip | + + # See https://github.com/gotenberg/gotenberg/issues/1227. + Scenario: Path As Filename + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | Gotenberg-Output-Filename | /tmp/foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be the following file(s) in the response: + | foo.pdf | diff --git a/test/integration/features/pdfengines_convert.feature b/test/integration/features/pdfengines_convert.feature new file mode 100644 index 000000000..e2157eb5e --- /dev/null +++ b/test/integration/features/pdfengines_convert.feature @@ -0,0 +1,191 @@ +# TODO: +# 1. PDF/UA-2. + +@pdfengines +@pdfengines-convert +Feature: /forms/pdfengines/convert + + Scenario: POST /forms/pdfengines/convert (Single PDF/A-1b) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfa | PDF/A-1b | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + + Scenario: POST /forms/pdfengines/convert (Single PDF/A-2b) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfa | PDF/A-2b | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be valid "PDF/A-2b" with a tolerance of 1 failed rule(s) + + Scenario: POST /forms/pdfengines/convert (Single PDF/A-3b) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfa | PDF/A-3b | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 1 failed rule(s) + + Scenario: POST /forms/pdfengines/convert (Single PDF/UA-1) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfua | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) + + Scenario: POST /forms/pdfengines/convert (Single PDF/A-1b & PDF/UA-1) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + Scenario: POST /forms/pdfengines/convert (Many PDFs) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | pdfa | PDF/A-1b | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + + Scenario: POST /forms/pdfengines/convert (Bad Request) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: either 'pdfa' or 'pdfua' form fields must be provided + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | pdfa | PDF/A-1b | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: no form file found for extensions: [.pdf] + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfa | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfua | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) + """ + + Scenario: POST /forms/pdfengines/convert (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfa | PDF/A-1b | field | + | Gotenberg-Trace | forms_pdfengines_convert | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then the response header "Gotenberg-Trace" should be "forms_pdfengines_convert" + Then the Gotenberg container should log the following entries: + | "trace":"forms_pdfengines_convert" | + + @output-filename + Scenario: POST /forms/pdfengines/convert (Output Filename - Single PDF) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfa | PDF/A-1b | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be the following file(s) in the response: + | foo.pdf | + + @output-filename + Scenario: POST /forms/pdfengines/convert (Output Filename - Many PDFs) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | pdfa | PDF/A-1b | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be the following file(s) in the response: + | foo.zip | + | page_1.pdf | + | page_2.pdf | + + @download-from + Scenario: POST /forms/pdfengines/convert (Download From) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field | + | pdfa | PDF/A-1b | field | + Then the file request header "X-Foo" should be "bar" + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + + @webhook + Scenario: POST /forms/pdfengines/convert (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfa | PDF/A-1b | field | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + Then the webhook request PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + + Scenario: POST /forms/pdfengines/convert (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfa | PDF/A-1b | field | + Then the response status code should be 401 + + Scenario: POST /foo/forms/pdfengines/convert (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfa | PDF/A-1b | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" diff --git a/test/integration/features/pdfengines_embed.feature b/test/integration/features/pdfengines_embed.feature new file mode 100644 index 000000000..4bf9ca7b8 --- /dev/null +++ b/test/integration/features/pdfengines_embed.feature @@ -0,0 +1,72 @@ +@pdfengines +@pdfengines-embed +@embed +Feature: /forms/pdfengines/embed + + Scenario: POST /forms/pdfengines/embed + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | page_1.pdf | + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should have the "embed_2.xml" file embedded + + @download-from + Scenario: POST /forms/pdfengines/embed with (Download From) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/embed_1.xml","embedded": true},{"url":"http://host.docker.internal:%d/static/testdata/embed_2.xml","embedded": false}] | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | page_1.pdf | + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should NOT have the "embed_2.xml" file embedded + + @webhook + Scenario: POST /forms/pdfengines/embed (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + Then the webhook request PDF(s) should have the "embed_1.xml" file embedded + Then the webhook request PDF(s) should have the "embed_2.xml" file embedded + + Scenario: POST /forms/pdfengines/embed (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + Then the response status code should be 401 + + Scenario: POST /foo/forms/pdfengines/embed (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/embed" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" diff --git a/test/integration/features/pdfengines_encrypt.feature b/test/integration/features/pdfengines_encrypt.feature new file mode 100644 index 000000000..0a072c7e6 --- /dev/null +++ b/test/integration/features/pdfengines_encrypt.feature @@ -0,0 +1,182 @@ +@pdfengines +@pdfengines-encrypt +@encrypt +Feature: /forms/pdfengines/encrypt + + Scenario: POST /forms/pdfengines/encrypt (default - user password only) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | userPassword | foo | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + Scenario: POST /forms/pdfengines/encrypt (default - both user and owner passwords) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | userPassword | foo | field | + | ownerPassword | bar | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + Scenario: POST /forms/pdfengines/encrypt (QPDF - user password only) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_ENCRYPT_ENGINES | qpdf | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | userPassword | foo | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + Scenario: POST /forms/pdfengines/encrypt (QPDF - both user and owner passwords) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_ENCRYPT_ENGINES | qpdf | + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | userPassword | foo | field | + | ownerPassword | bar | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + Scenario: POST /forms/pdfengines/encrypt (PDFtk - user password only) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_ENCRYPT_ENGINES | pdftk | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | userPassword | foo | field | + Then the response status code should be 400 + Then the response body should match string: + """ + pdftk: both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow + """ + + Scenario: POST /forms/pdfengines/encrypt (PDFtk - both user and owner passwords) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_ENCRYPT_ENGINES | pdftk | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | userPassword | foo | field | + | ownerPassword | bar | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + Scenario: POST /forms/pdfengines/encrypt (pdfcpu - user password only) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_ENCRYPT_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | userPassword | foo | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + Scenario: POST /forms/pdfengines/encrypt (pdfcpu - both user and owner passwords) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_ENCRYPT_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | userPassword | foo | field | + | ownerPassword | bar | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + Scenario: POST /forms/pdfengines/encrypt (Many PDFs) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | userPassword | foo | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then the response PDF(s) should be encrypted + + Scenario: POST /forms/pdfengines/encrypt (Bad Request) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: form field 'userPassword' is required + """ + + Scenario: POST /forms/pdfengines/encrypt (Routes Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_DISABLE_ROUTES | true | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + Then the response status code should be 404 + + Scenario: POST /forms/pdfengines/encrypt (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | userPassword | foo | field | + | Gotenberg-Trace | forms_pdfengines_encrypt | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then the response header "Gotenberg-Trace" should be "forms_pdfengines_encrypt" + Then the Gotenberg container should log the following entries: + | "trace":"forms_pdfengines_encrypt" | + + @download-from + Scenario: POST /forms/pdfengines/encrypt (Download From) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field | + | userPassword | foo | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + @webhook + Scenario: POST /forms/pdfengines/encrypt (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | userPassword | foo | field | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + Then the response PDF(s) should be encrypted + + Scenario: POST /forms/pdfengines/encrypt (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + Then the response status code should be 401 + + Scenario: POST /foo/forms/pdfengines/encrypt (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | userPassword | foo | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" diff --git a/test/integration/features/pdfengines_flatten.feature b/test/integration/features/pdfengines_flatten.feature new file mode 100644 index 000000000..11529a734 --- /dev/null +++ b/test/integration/features/pdfengines_flatten.feature @@ -0,0 +1,120 @@ +@pdfengines +@pdfengines-flatten +@flatten +Feature: /forms/pdfengines/flatten + + Scenario: POST /forms/pdfengines/flatten (Single PDF) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be flatten + + Scenario: POST /forms/pdfengines/flatten (Many PDFs) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then the response PDF(s) should be flatten + + Scenario: POST /forms/pdfengines/flatten (Bad Request) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: no form file found for extensions: [.pdf] + """ + + Scenario: POST /forms/pdfengines/flatten (Routes Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_DISABLE_ROUTES | true | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + Then the response status code should be 404 + + Scenario: POST /forms/pdfengines/flatten (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | Gotenberg-Trace | forms_pdfengines_flatten | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then the response header "Gotenberg-Trace" should be "forms_pdfengines_flatten" + Then the Gotenberg container should log the following entries: + | "trace":"forms_pdfengines_flatten" | + + @output-filename + Scenario: POST /forms/pdfengines/flatten (Output Filename - Single PDF) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be the following file(s) in the response: + | foo.pdf | + + @output-filename + Scenario: POST /forms/pdfengines/flatten (Output Filename - Many PDFs) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be the following file(s) in the response: + | foo.zip | + | page_1.pdf | + | page_2.pdf | + + @download-from + Scenario: POST /forms/pdfengines/flatten (Download From) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field | + Then the file request header "X-Foo" should be "bar" + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then the response PDF(s) should be flatten + + @webhook + Scenario: POST /forms/pdfengines/flatten (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + Then the response PDF(s) should be flatten + + Scenario: POST /forms/pdfengines/flatten (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + Then the response status code should be 401 + + Scenario: POST /foo/forms/pdfengines/flatten (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" diff --git a/test/integration/features/pdfengines_merge.feature b/test/integration/features/pdfengines_merge.feature new file mode 100644 index 000000000..bb520c78c --- /dev/null +++ b/test/integration/features/pdfengines_merge.feature @@ -0,0 +1,401 @@ +@pdfengines +@pdfengines-encrypt +@merge +Feature: /forms/pdfengines/merge + + Scenario: POST /forms/pdfengines/merge (default) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + + Scenario: POST /forms/pdfengines/merge (QPDF) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_MERGE_ENGINES | qpdf | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + + Scenario: POST /forms/pdfengines/merge (pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_MERGE_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + + Scenario: POST /forms/pdfengines/merge (PDFtk) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_MERGE_ENGINES | pdftk | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + + Scenario: POST /forms/pdfengines/merge (Bad Request) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: no form file found for extensions: [.pdf] + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | pdfa | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | pdfua | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | metadata | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a')) + """ + + @convert + Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + @metadata + Scenario: POST /forms/pdfengines/merge (Metadata) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "foo.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + @flatten + Scenario: POST /forms/pdfengines/merge (Flatten) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | flatten | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the response PDF(s) should be flatten + + @encrypt + Scenario: POST /forms/pdfengines/merge (Encrypt - user password only) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | userPassword | foo | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + @encrypt + Scenario: POST /forms/pdfengines/merge (Encrypt - both user and owner passwords) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | userPassword | foo | field | + | ownerPassword | bar | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be encrypted + + @embed + Scenario: POST /foo/forms/pdfengines/merge (Embeds) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should have the "embed_2.xml" file embedded + + # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. + @convert + @metadata + @flatten + @embed + Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | flatten | true | field | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) + Then the response PDF(s) should be flatten + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should have the "embed_2.xml" file embedded + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "foo.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + Scenario: POST /forms/pdfengines/merge (Routes Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_DISABLE_ROUTES | true | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + Then the response status code should be 404 + + Scenario: POST /forms/pdfengines/merge (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | Gotenberg-Trace | forms_pdfengines_merge | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then the response header "Gotenberg-Trace" should be "forms_pdfengines_merge" + Then the Gotenberg container should log the following entries: + | "trace":"forms_pdfengines_merge" | + + @download-from + Scenario: POST /forms/pdfengines/merge (Download From) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf"},{"url":"http://host.docker.internal:%d/static/testdata/page_2.pdf"}] | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + + @webhook + Scenario: POST /forms/pdfengines/merge (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | Gotenberg-Output-Filename | foo | header | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + Then there should be the following file(s) in the webhook request: + | foo.pdf | + Then the "foo.pdf" PDF should have 2 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "foo.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + + Scenario: POST /forms/pdfengines/merge (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + Then the response status code should be 401 + + Scenario: POST /foo/forms/pdfengines/merge (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" diff --git a/test/integration/features/pdfengines_metadata.feature b/test/integration/features/pdfengines_metadata.feature new file mode 100644 index 000000000..336c35076 --- /dev/null +++ b/test/integration/features/pdfengines_metadata.feature @@ -0,0 +1,295 @@ +@pdfengines +@pdfengines-metadata +@metadata +Feature: /forms/pdfengines/{write|read} + + Scenario: POST /forms/pdfengines/metadata/{write|read} (Single PDF) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "foo.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + Scenario: POST /forms/pdfengines/metadata/{write|read} (Many PDFs) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files. | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/page_1.pdf | file | + | files | teststore/page_2.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "page_1.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + }, + "page_2.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + Scenario: POST /forms/pdfengines/metadata/write (Bad Request) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'metadata' is required; no form file found for extensions: [.pdf] + """ + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | metadata | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a')) + """ + + Scenario: POST /forms/pdfengines/metadata/read (Bad Request) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: no form file found for extensions: [.pdf] + """ + + Scenario: POST /forms/pdfengines/metadata/write (Routes Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_DISABLE_ROUTES | true | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + Then the response status code should be 404 + + Scenario: POST /forms/pdfengines/metadata/read (Routes Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_DISABLE_ROUTES | true | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + Then the response status code should be 404 + + Scenario: POST /forms/pdfengines/metadata/write (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | Gotenberg-Trace | forms_pdfengines_metadata_write | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then the response header "Gotenberg-Trace" should be "forms_pdfengines_metadata_write" + Then the Gotenberg container should log the following entries: + | "trace":"forms_pdfengines_metadata_write" | + + Scenario: POST /forms/pdfengines/metadata/read (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | Gotenberg-Trace | forms_pdfengines_metadata_read | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response header "Gotenberg-Trace" should be "forms_pdfengines_metadata_read" + Then the Gotenberg container should log the following entries: + | "trace":"forms_pdfengines_metadata_read" | + + @output-filename + Scenario: POST /forms/pdfengines/metadata/write (Output Filename - Single PDF) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be the following file(s) in the response: + | foo.pdf | + + @output-filename + Scenario: POST /forms/pdfengines/metadata/write (Output Filename - Many PDFs) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be the following file(s) in the response: + | foo.zip | + | page_1.pdf | + | page_2.pdf | + + @download-from + Scenario: POST /forms/pdfengines/metadata/write (Download From) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field | + | Gotenberg-Output-Filename | foo | header | + Then the file request header "X-Foo" should be "bar" + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + + @download-from + Scenario: POST /forms/pdfengines/metadata/read (Download From) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field | + Then the file request header "X-Foo" should be "bar" + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + + @webhook + Scenario: POST /forms/pdfengines/metadata/write (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "foo.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + @webhook + Scenario: POST /forms/pdfengines/metadata/read (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/json" + Then the webhook request body should match JSON: + """ + { + "status": 400, + "message": "The webhook middleware can only work with multipart/form-data routes that results in output files" + } + """ + + Scenario: POST /forms/pdfengines/metadata/write (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + Then the response status code should be 401 + + Scenario: POST /forms/pdfengines/metadata/read (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + Then the response status code should be 401 + + Scenario: POST /foo/forms/pdfengines/metadata/{write|read} (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/foo.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" diff --git a/test/integration/features/pdfengines_split.feature b/test/integration/features/pdfengines_split.feature new file mode 100644 index 000000000..ae4dac4f4 --- /dev/null +++ b/test/integration/features/pdfengines_split.feature @@ -0,0 +1,690 @@ +@pdfengines +@pdfengines-split +@split +Feature: /forms/pdfengines/split + + Scenario: POST /forms/pdfengines/split (Intervals - Default) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3_0.pdf | + | pages_3_1.pdf | + Then the "pages_3_0.pdf" PDF should have 2 page(s) + Then the "pages_3_1.pdf" PDF should have 1 page(s) + Then the "pages_3_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "pages_3_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "pages_3_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + Scenario: POST /forms/pdfengines/split (Pages - Default) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | pages | field | + | splitSpan | 2- | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3_0.pdf | + | pages_3_1.pdf | + Then the "pages_3_0.pdf" PDF should have 1 page(s) + Then the "pages_3_1.pdf" PDF should have 1 page(s) + Then the "pages_3_0.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "pages_3_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + Scenario: POST /forms/pdfengines/split (Pages & Unify - Default) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | pages | field | + | splitSpan | 2- | field | + | splitUnify | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3.pdf | + Then the "pages_3.pdf" PDF should have 2 page(s) + Then the "pages_3.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "pages_3.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + + Scenario: POST /forms/pdfengines/split (Intervals - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_SPLIT_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3_0.pdf | + | pages_3_1.pdf | + Then the "pages_3_0.pdf" PDF should have 2 page(s) + Then the "pages_3_1.pdf" PDF should have 1 page(s) + Then the "pages_3_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "pages_3_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "pages_3_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + Scenario: POST /forms/pdfengines/split (Pages - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_SPLIT_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | pages | field | + | splitSpan | 2- | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3_0.pdf | + | pages_3_1.pdf | + Then the "pages_3_0.pdf" PDF should have 1 page(s) + Then the "pages_3_1.pdf" PDF should have 1 page(s) + Then the "pages_3_0.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "pages_3_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + Scenario: POST /forms/pdfengines/split (Pages & Unify - pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_SPLIT_ENGINES | pdfcpu | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | pages | field | + | splitSpan | 2- | field | + | splitUnify | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3.pdf | + Then the "pages_3.pdf" PDF should have 2 page(s) + Then the "pages_3.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "pages_3.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + + Scenario: POST /forms/pdfengines/split (Pages & Unify - QPDF) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_SPLIT_ENGINES | qpdf | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | pages | field | + | splitSpan | 2-z | field | + | splitUnify | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3.pdf | + Then the "pages_3.pdf" PDF should have 2 page(s) + Then the "pages_3.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "pages_3.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + + Scenario: POST /forms/pdfengines/split (Pages & Unify - PDFtk) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_SPLIT_ENGINES | pdftk | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | pages | field | + | splitSpan | 2-end | field | + | splitUnify | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3.pdf | + Then the "pages_3.pdf" PDF should have 2 page(s) + Then the "pages_3.pdf" PDF should have the following content at page 1: + """ + Page 2 + """ + Then the "pages_3.pdf" PDF should have the following content at page 2: + """ + Page 3 + """ + + Scenario: POST /forms/pdfengines/split (Many PDFs - Lot of Pages) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_12.pdf | file | + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 1 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 15 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3_0.pdf | + | pages_3_1.pdf | + | pages_3_2.pdf | + | pages_12_0.pdf | + | pages_12_1.pdf | + | pages_12_2.pdf | + | pages_12_3.pdf | + | pages_12_4.pdf | + | pages_12_5.pdf | + | pages_12_6.pdf | + | pages_12_7.pdf | + | pages_12_8.pdf | + | pages_12_9.pdf | + | pages_12_10.pdf | + | pages_12_11.pdf | + Then the "pages_3_0.pdf" PDF should have 1 page(s) + Then the "pages_3_2.pdf" PDF should have 1 page(s) + Then the "pages_12_0.pdf" PDF should have 1 page(s) + Then the "pages_12_11.pdf" PDF should have 1 page(s) + Then the "pages_3_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "pages_3_2.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + Then the "pages_12_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "pages_12_11.pdf" PDF should have the following content at page 1: + """ + Page 12 + """ + + Scenario: POST /forms/pdfengines/split (Bad Request) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'splitMode' is required; form field 'splitSpan' is required; no form file found for extensions: [.pdf] + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | foo | field | + | splitSpan | 2 | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'splitMode' is invalid (got 'foo', resulting to wrong value, expected either 'intervals' or 'pages') + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'splitSpan' is invalid (got 'foo', resulting to strconv.Atoi: parsing "foo": invalid syntax) + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | pages | field | + | splitSpan | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | pdfa | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | pdfua | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | metadata | foo | field | + Then the response status code should be 400 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a')) + """ + + @convert + Scenario: POST /forms/pdfengines/split (PDF/A-1b & PDF/UA-1) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3_0.pdf | + | pages_3_1.pdf | + Then the "pages_3_0.pdf" PDF should have 2 page(s) + Then the "pages_3_1.pdf" PDF should have 1 page(s) + Then the "pages_3_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "pages_3_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "pages_3_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s) + + @metadata + Scenario: POST /forms/pdfengines/split (Metadata) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3_0.pdf | + | pages_3_1.pdf | + Then the "pages_3_0.pdf" PDF should have 2 page(s) + Then the "pages_3_1.pdf" PDF should have 1 page(s) + Then the "pages_3_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "pages_3_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "pages_3_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/pages_3_0.pdf | file | + | files | teststore/pages_3_1.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "pages_3_0.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + }, + "pages_3_1.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + @flatten + Scenario: POST /forms/pdfengines/split (Flatten) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | flatten | true | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3_0.pdf | + | pages_3_1.pdf | + Then the "pages_3_0.pdf" PDF should have 2 page(s) + Then the "pages_3_1.pdf" PDF should have 1 page(s) + Then the "pages_3_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "pages_3_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "pages_3_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + Then the response PDF(s) should be flatten + + @encrypt + Scenario: POST /forms/pdfengines/split (Encrypt - user password only) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | userPassword | foo | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then the response PDF(s) should be encrypted + + @encrypt + Scenario: POST /forms/pdfengines/split (Encrypt - both user and owner passwords) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | userPassword | foo | field | + | ownerPassword | bar | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then the response PDF(s) should be encrypted + + @embed + Scenario: POST /foo/forms/pdfengines/split (Embeds) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + Then the response status code should be 200 + And the response header "Content-Type" should be "application/zip" + And there should be 2 PDF(s) in the response + And there should be the following file(s) in the response: + | pages_3_0.pdf | + | pages_3_1.pdf | + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should have the "embed_2.xml" file embedded + + # FIXME: once decrypt is done, add encrypt and check after the content of the PDFs. + @convert + @metadata + @flatten + @embed + Scenario: POST /forms/pdfengines/split (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | flatten | true | field | + | embeds | testdata/embed_1.xml | file | + | embeds | testdata/embed_2.xml | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | pages_3_0.pdf | + | pages_3_1.pdf | + Then the "pages_3_0.pdf" PDF should have 2 page(s) + Then the "pages_3_1.pdf" PDF should have 1 page(s) + Then the "pages_3_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "pages_3_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "pages_3_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s) + Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) + Then the response PDF(s) should be flatten + Then the response PDF(s) should have the "embed_1.xml" file embedded + Then the response PDF(s) should have the "embed_2.xml" file embedded + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): + | files | teststore/pages_3_0.pdf | file | + | files | teststore/pages_3_1.pdf | file | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/json" + Then the response body should match JSON: + """ + { + "pages_3_0.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + }, + "pages_3_1.pdf": { + "Author": "Julien Neuhart", + "Copyright": "Julien Neuhart", + "CreateDate": "2006:09:18 16:27:50-04:00", + "Creator": "Gotenberg", + "Keywords": ["first", "second"], + "Marked": true, + "ModDate": "2006:09:18 16:27:50-04:00", + "PDFVersion": 1.7, + "Producer": "Gotenberg", + "Subject": "Sample", + "Title": "Sample", + "Trapped": "Unknown" + } + } + """ + + Scenario: POST /forms/pdfengines/split (Routes Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_DISABLE_ROUTES | true | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + Then the response status code should be 404 + + Scenario: POST /forms/pdfengines/split (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | Gotenberg-Trace | forms_pdfengines_split | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then the response header "Gotenberg-Trace" should be "forms_pdfengines_split" + Then the Gotenberg container should log the following entries: + | "trace":"forms_pdfengines_split" | + + @output-filename + Scenario: POST /forms/pdfengines/split (Output Filename - Single PDF) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | pages | field | + | splitSpan | 2- | field | + | splitUnify | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be the following file(s) in the response: + | foo.pdf | + + @output-filename + Scenario: POST /forms/pdfengines/split (Output Filename - Many PDFs) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be the following file(s) in the response: + | foo.zip | + | pages_3_0.pdf | + | pages_3_1.pdf | + + @download-from + Scenario: POST /forms/pdfengines/split (Download From) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/pages_3.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field | + | splitMode | intervals | field | + | splitSpan | 2 | field | + Then the response status code should be 200 + Then the file request header "X-Foo" should be "bar" + Then the response header "Content-Type" should be "application/zip" + + @webhook + Scenario: POST /forms/pdfengines/split (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the webhook request + Then there should be the following file(s) in the webhook request: + | pages_3_0.pdf | + | pages_3_1.pdf | + Then the "pages_3_0.pdf" PDF should have 2 page(s) + Then the "pages_3_1.pdf" PDF should have 1 page(s) + Then the "pages_3_0.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + Then the "pages_3_0.pdf" PDF should have the following content at page 2: + """ + Page 2 + """ + Then the "pages_3_1.pdf" PDF should have the following content at page 1: + """ + Page 3 + """ + + Scenario: POST /forms/pdfengines/split (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + Then the response status code should be 401 + + Scenario: POST /foo/forms/pdfengines/split (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" diff --git a/test/integration/features/prometheus_metrics.feature b/test/integration/features/prometheus_metrics.feature new file mode 100644 index 000000000..5a12402f3 --- /dev/null +++ b/test/integration/features/prometheus_metrics.feature @@ -0,0 +1,91 @@ +# TODO: +# 1. Count restarts. +# 2. Count queue size. + +@prometheus-metrics +Feature: /prometheus/metrics + + Scenario: GET /prometheus/metrics (Enabled) + Given I have a default Gotenberg container + When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint + Then the response status code should be 200 + Then the response header "Content-Type" should be "text/plain; version=0.0.4; charset=utf-8; escaping=underscores" + Then the response body should match string: + """ + # HELP gotenberg_chromium_requests_queue_size Current number of Chromium conversion requests waiting to be treated. + # TYPE gotenberg_chromium_requests_queue_size gauge + gotenberg_chromium_requests_queue_size 0 + # HELP gotenberg_chromium_restarts_count Current number of Chromium restarts. + # TYPE gotenberg_chromium_restarts_count gauge + gotenberg_chromium_restarts_count 0 + # HELP gotenberg_libreoffice_requests_queue_size Current number of LibreOffice conversion requests waiting to be treated. + # TYPE gotenberg_libreoffice_requests_queue_size gauge + gotenberg_libreoffice_requests_queue_size 0 + # HELP gotenberg_libreoffice_restarts_count Current number of LibreOffice restarts. + # TYPE gotenberg_libreoffice_restarts_count gauge + gotenberg_libreoffice_restarts_count 0 + + """ + Then the Gotenberg container should log the following entries: + | "path":"/prometheus/metrics" | + + Scenario: GET /prometheus/metrics (Custom Namespace) + Given I have a Gotenberg container with the following environment variable(s): + | PROMETHEUS_NAMESPACE | foo | + When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint + Then the response status code should be 200 + Then the response header "Content-Type" should be "text/plain; version=0.0.4; charset=utf-8; escaping=underscores" + Then the response body should match string: + """ + # HELP foo_chromium_requests_queue_size Current number of Chromium conversion requests waiting to be treated. + # TYPE foo_chromium_requests_queue_size gauge + foo_chromium_requests_queue_size 0 + # HELP foo_chromium_restarts_count Current number of Chromium restarts. + # TYPE foo_chromium_restarts_count gauge + foo_chromium_restarts_count 0 + # HELP foo_libreoffice_requests_queue_size Current number of LibreOffice conversion requests waiting to be treated. + # TYPE foo_libreoffice_requests_queue_size gauge + foo_libreoffice_requests_queue_size 0 + # HELP foo_libreoffice_restarts_count Current number of LibreOffice restarts. + # TYPE foo_libreoffice_restarts_count gauge + foo_libreoffice_restarts_count 0 + + """ + + Scenario: GET /prometheus/metrics (Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | PROMETHEUS_DISABLE_COLLECT | true | + When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint + Then the response status code should be 404 + + Scenario: GET /prometheus/metrics (No Logging) + Given I have a Gotenberg container with the following environment variable(s): + | PROMETHEUS_DISABLE_ROUTE_LOGGING | true | + When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint + Then the response status code should be 200 + Then the Gotenberg container should NOT log the following entries: + | "path":"/prometheus/metrics" | + + Scenario: GET /prometheus/metrics (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint with the following header(s): + | Gotenberg-Trace | prometheus_metrics | + Then the response status code should be 200 + Then the response header "Gotenberg-Trace" should be "prometheus_metrics" + Then the Gotenberg container should log the following entries: + | "trace":"prometheus_metrics" | + + Scenario: GET /prometheus/metrics (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint + Then the response status code should be 401 + + Scenario: GET /foo/prometheus/metrics (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "GET" request to Gotenberg at the "/foo/prometheus/metrics" endpoint + Then the response status code should be 200 diff --git a/test/integration/features/root.feature b/test/integration/features/root.feature new file mode 100644 index 000000000..a1c6f5062 --- /dev/null +++ b/test/integration/features/root.feature @@ -0,0 +1,63 @@ +@root +Feature: / + + Scenario: GET / + Given I have a default Gotenberg container + When I make a "GET" request to Gotenberg at the "/" endpoint + Then the response status code should be 200 + Then the response header "Content-Type" should be "text/html; charset=UTF-8" + Then the response body should match string: + """ + Hey, Gotenberg has no UI, it's an API. Head to the documentation to learn how to interact with it ๐Ÿš€ + """ + + Scenario: GET / (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "GET" request to Gotenberg at the "/" endpoint with the following header(s): + | Gotenberg-Trace | root | + Then the response status code should be 200 + Then the response header "Gotenberg-Trace" should be "root" + Then the Gotenberg container should log the following entries: + | "trace":"root" | + + Scenario: GET / (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "GET" request to Gotenberg at the "/" endpoint + Then the response status code should be 401 + + Scenario: GET /foo/ (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ROOT_PATH | /foo/ | + When I make a "GET" request to Gotenberg at the "/foo/" endpoint + Then the response status code should be 200 + + Scenario: GET /favicon.ico + Given I have a default Gotenberg container + When I make a "GET" request to Gotenberg at the "/favicon.ico" endpoint + Then the response status code should be 204 + + Scenario: GET /favicon.ico (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "GET" request to Gotenberg at the "/favicon.ico" endpoint with the following header(s): + | Gotenberg-Trace | favicon | + Then the response status code should be 204 + Then the response header "Gotenberg-Trace" should be "favicon" + Then the Gotenberg container should log the following entries: + | "trace":"favicon" | + + Scenario: GET /favicon.ico (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "GET" request to Gotenberg at the "/favicon.ico" endpoint + Then the response status code should be 401 + + Scenario: GET /foo/favicon.ico (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ROOT_PATH | /foo/ | + When I make a "GET" request to Gotenberg at the "/foo/favicon.ico" endpoint + Then the response status code should be 204 diff --git a/test/integration/features/version.feature b/test/integration/features/version.feature new file mode 100644 index 000000000..029755053 --- /dev/null +++ b/test/integration/features/version.feature @@ -0,0 +1,36 @@ +@version +Feature: /version + + Scenario: GET /version + Given I have a default Gotenberg container + When I make a "GET" request to Gotenberg at the "/version" endpoint + Then the response status code should be 200 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + {version} + """ + + Scenario: GET /version (Gotenberg Trace) + Given I have a default Gotenberg container + When I make a "GET" request to Gotenberg at the "/version" endpoint with the following header(s): + | Gotenberg-Trace | version | + Then the response status code should be 200 + Then the response header "Gotenberg-Trace" should be "version" + Then the Gotenberg container should log the following entries: + | "trace":"version" | + + Scenario: GET /version (Basic Auth) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_BASIC_AUTH | true | + | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | + | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | + When I make a "GET" request to Gotenberg at the "/version" endpoint + Then the response status code should be 401 + + Scenario: GET /foo/version (Root Path) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_ROOT_PATH | /foo/ | + When I make a "GET" request to Gotenberg at the "/foo/version" endpoint + Then the response status code should be 200 diff --git a/test/integration/features/webhook.feature b/test/integration/features/webhook.feature new file mode 100644 index 000000000..072800eb4 --- /dev/null +++ b/test/integration/features/webhook.feature @@ -0,0 +1,46 @@ +# TODO: +# 1. Other HTTP Methods +# 2. Errors + +@webhook +Feature: Webhook + + Scenario: Default + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + + Scenario: Extra HTTP Headers + Given I have a default Gotenberg container + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + | Gotenberg-Webhook-Extra-Http-Headers | {"X-Foo":"bar","Content-Disposition":"inline"} | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then the webhook request header "X-Foo" should be "bar" + # https://github.com/gotenberg/gotenberg/issues/1165 + Then the webhook request header "Content-Disposition" should be "inline" + Then there should be 1 PDF(s) in the webhook request + + Scenario: Synchronous + Given I have a Gotenberg container with the following environment variable(s): + | WEBHOOK_ENABLE_SYNC_MODE | true | + Given I have a webhook server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request diff --git a/test/integration/main_test.go b/test/integration/main_test.go new file mode 100644 index 000000000..67144b22f --- /dev/null +++ b/test/integration/main_test.go @@ -0,0 +1,56 @@ +//go:build integration + +package integration + +import ( + "os" + "runtime" + "testing" + + "github.com/cucumber/godog" + "github.com/cucumber/godog/colors" + flag "github.com/spf13/pflag" + + "github.com/gotenberg/gotenberg/v8/test/integration/scenario" +) + +func TestMain(m *testing.M) { + repository := flag.String("gotenberg-docker-repository", "", "") + version := flag.String("gotenberg-version", "", "") + platform := flag.String("gotenberg-container-platform", "", "") + noConcurrency := flag.Bool("no-concurrency", false, "") + tags := flag.String("tags", "", "") + flag.Parse() + + if *platform == "" { + switch runtime.GOARCH { + case "arm64": + *platform = "linux/arm64" + default: + *platform = "linux/amd64" + } + } + + scenario.GotenbergDockerRepository = *repository + scenario.GotenbergVersion = *version + scenario.GotenbergContainerPlatform = *platform + + concurrency := runtime.NumCPU() + if *noConcurrency { + concurrency = 0 + } + + code := godog.TestSuite{ + Name: "integration", + ScenarioInitializer: scenario.InitializeScenario, + Options: &godog.Options{ + Format: "pretty", + Paths: []string{"features"}, + Output: colors.Colored(os.Stdout), + Concurrency: concurrency, + Tags: *tags, + }, + }.Run() + + os.Exit(code) +} diff --git a/test/integration/scenario/compare.go b/test/integration/scenario/compare.go new file mode 100644 index 000000000..5f61337f0 --- /dev/null +++ b/test/integration/scenario/compare.go @@ -0,0 +1,57 @@ +package scenario + +import ( + "fmt" + "reflect" +) + +func compareJson(expected, actual interface{}) error { + // Handle maps (JSON objects). + expectedMap, ok := expected.(map[string]interface{}) + if ok { + actualMap, ok := actual.(map[string]interface{}) + if !ok { + return fmt.Errorf("expected an object, but actual is: %T", actual) + } + // For each key in expected, compare if the expected value is not + // "ignore". + for key, expVal := range expectedMap { + if str, isStr := expVal.(string); isStr && str == "ignore" { + continue // Skip. + } + actVal, exists := actualMap[key] + if !exists { + return fmt.Errorf("missing expected key %q", key) + } + if err := compareJson(expVal, actVal); err != nil { + return fmt.Errorf("key %q: %w", key, err) + } + } + return nil + } + + // Handle slices (JSON arrays). + expectedSlice, ok := expected.([]interface{}) + if ok { + actualSlice, ok := actual.([]interface{}) + if !ok { + return fmt.Errorf("expected an array, but actual is: %T", actual) + } + if len(expectedSlice) != len(actualSlice) { + return fmt.Errorf("expected array length to be: %d, but actual is: %d", len(expectedSlice), len(actualSlice)) + } + for i := range expectedSlice { + if err := compareJson(expectedSlice[i], actualSlice[i]); err != nil { + return fmt.Errorf("at index %d: %w", i, err) + } + } + return nil + } + + // For other types, compare directly. + if !reflect.DeepEqual(expected, actual) { + return fmt.Errorf("expected %v (%T) but got %v (%T)", expected, expected, actual, actual) + } + + return nil +} diff --git a/test/integration/scenario/containers.go b/test/integration/scenario/containers.go new file mode 100644 index 000000000..c4dc1a559 --- /dev/null +++ b/test/integration/scenario/containers.go @@ -0,0 +1,137 @@ +package scenario + +import ( + "context" + "fmt" + "io" + "path/filepath" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/network" + "github.com/testcontainers/testcontainers-go/wait" +) + +var ( + GotenbergDockerRepository string + GotenbergVersion string + GotenbergContainerPlatform string +) + +type noopLogger struct{} + +func (n *noopLogger) Printf(format string, v ...interface{}) { + // NOOP +} + +func startGotenbergContainer(ctx context.Context, env map[string]string) (*testcontainers.DockerNetwork, testcontainers.Container, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + n, err := network.New(ctx) + if err != nil { + return nil, nil, fmt.Errorf("create Gotenberg container network: %w", err) + } + + healthPath := "/health" + if env["API_ROOT_PATH"] != "" { + healthPath = fmt.Sprintf("%shealth", env["API_ROOT_PATH"]) + } + + req := testcontainers.ContainerRequest{ + Image: fmt.Sprintf("gotenberg/%s:%s", GotenbergDockerRepository, GotenbergVersion), + ImagePlatform: GotenbergContainerPlatform, + ExposedPorts: []string{"3000/tcp"}, + HostConfigModifier: func(hostConfig *container.HostConfig) { + hostConfig.ExtraHosts = []string{"host.docker.internal:host-gateway"} + }, + Networks: []string{n.Name}, + WaitingFor: wait.ForHTTP(healthPath), + Env: env, + } + + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + Logger: &noopLogger{}, + }) + if err != nil { + err = fmt.Errorf("start new Gotenberg container: %w", err) + } + + return n, c, err +} + +func execCommandInIntegrationToolsContainer(ctx context.Context, cmd []string, path string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + req := testcontainers.ContainerRequest{ + Image: "gotenberg/integration-tools:latest", + ImagePlatform: GotenbergContainerPlatform, + Files: []testcontainers.ContainerFile{ + { + HostFilePath: path, + ContainerFilePath: filepath.Base(path), + FileMode: 0o700, + }, + }, + Cmd: []string{"tail", "-f", "/dev/null"}, // Keeps container running indefinitely. + } + + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + Logger: &noopLogger{}, + }) + if err != nil { + return "", fmt.Errorf("start new Integration Tools container: %w", err) + } + defer func(c testcontainers.Container, ctx context.Context) { + err := c.Terminate(ctx) + if err != nil { + fmt.Printf("terminate container: %v\n", err) + } + }(c, ctx) + + _, output, err := c.Exec(ctx, cmd) + if err != nil { + return "", fmt.Errorf("exec %q: %w", cmd, err) + } + + b, err := io.ReadAll(output) + if err != nil { + return "", fmt.Errorf("read output: %w", err) + } + + return string(b), nil +} + +func containerHttpEndpoint(ctx context.Context, container testcontainers.Container, port nat.Port) (string, error) { + ip, err := container.Host(ctx) + if err != nil { + return "", fmt.Errorf("get container IP: %w", err) + } + mapped, err := container.MappedPort(ctx, port) + if err != nil { + return "", fmt.Errorf("get container port: %w", err) + } + return fmt.Sprintf("http://%s:%s", ip, mapped.Port()), nil +} + +func containerLogEntries(ctx context.Context, container testcontainers.Container) (string, error) { + logReader, err := container.Logs(ctx) + if err != nil { + return "", fmt.Errorf("get container log entries: %w", err) + } + defer logReader.Close() + + logsBytes, err := io.ReadAll(logReader) + if err != nil { + return "", fmt.Errorf("read container log entries: %w", err) + } + + return string(logsBytes), nil +} diff --git a/test/integration/scenario/doc.go b/test/integration/scenario/doc.go new file mode 100644 index 000000000..a874ef8f0 --- /dev/null +++ b/test/integration/scenario/doc.go @@ -0,0 +1,2 @@ +// Package scenario gathers all steps used in the features. +package scenario diff --git a/test/integration/scenario/http.go b/test/integration/scenario/http.go new file mode 100644 index 000000000..7d88d02b9 --- /dev/null +++ b/test/integration/scenario/http.go @@ -0,0 +1,83 @@ +package scenario + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" +) + +func doRequest(method, url string, headers map[string]string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("create a request: %w", err) + } + + for header, value := range headers { + req.Header.Set(header, value) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send a request: %w", err) + } + + return resp, nil +} + +func doFormDataRequest(method, url string, fields map[string]string, files map[string][]string, headers map[string]string) (*http.Response, error) { + var b bytes.Buffer + writer := multipart.NewWriter(&b) + + for name, value := range fields { + err := writer.WriteField(name, value) + if err != nil { + return nil, fmt.Errorf("write field %q: %w", name, err) + } + } + + for name, paths := range files { + for _, path := range paths { + part, err := writer.CreateFormFile(name, filepath.Base(path)) + if err != nil { + return nil, fmt.Errorf("create form file %q: %w", filepath.Base(path), err) + } + + reader, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open file %q: %w", path, err) + } + defer reader.Close() + + _, err = io.Copy(part, reader) + if err != nil { + return nil, fmt.Errorf("copy file %q: %w", path, err) + } + } + } + + err := writer.Close() + if err != nil { + return nil, fmt.Errorf("close writer: %w", err) + } + + req, err := http.NewRequest(method, url, &b) + if err != nil { + return nil, fmt.Errorf("create a request: %w", err) + } + + for header, value := range headers { + req.Header.Set(header, value) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send a request: %w", err) + } + + return resp, nil +} diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go new file mode 100644 index 000000000..32336f90f --- /dev/null +++ b/test/integration/scenario/scenario.go @@ -0,0 +1,1007 @@ +package scenario + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/cucumber/godog" + "github.com/google/uuid" + "github.com/mholt/archives" + "github.com/testcontainers/testcontainers-go" +) + +type scenario struct { + resp *httptest.ResponseRecorder + workdir string + gotenbergContainer testcontainers.Container + gotenbergContainerNetwork *testcontainers.DockerNetwork + server *server + hostPort int +} + +func (s *scenario) reset(ctx context.Context) error { + s.resp = httptest.NewRecorder() + + err := os.RemoveAll(s.workdir) + if err != nil { + return fmt.Errorf("remove workdir %q: %w", s.workdir, err) + } + s.workdir = "" + + if s.server == nil { + return nil + } + + err = s.server.stop(ctx) + if err != nil { + return fmt.Errorf("stop server: %w", err) + } + + return nil +} + +func (s *scenario) iHaveADefaultGotenbergContainer(ctx context.Context) error { + n, c, err := startGotenbergContainer(ctx, nil) + if err != nil { + return fmt.Errorf("create Gotenberg container: %s", err) + } + s.gotenbergContainerNetwork = n + s.gotenbergContainer = c + return nil +} + +func (s *scenario) iHaveAGotenbergContainerWithTheFollowingEnvironmentVariables(ctx context.Context, envTable *godog.Table) error { + env := make(map[string]string) + for _, row := range envTable.Rows { + env[row.Cells[0].Value] = row.Cells[1].Value + } + n, c, err := startGotenbergContainer(ctx, env) + if err != nil { + return fmt.Errorf("create Gotenberg container: %s", err) + } + s.gotenbergContainerNetwork = n + s.gotenbergContainer = c + return nil +} + +func (s *scenario) iHaveAServer(ctx context.Context) error { + srv, err := newServer(ctx, s.workdir) + if err != nil { + return fmt.Errorf("create server: %s", err) + } + s.server = srv + port, err := s.server.start(ctx) + if err != nil { + return fmt.Errorf("start server: %s", err) + } + s.hostPort = port + return nil +} + +func (s *scenario) iMakeARequestToGotenberg(ctx context.Context, method, endpoint string) error { + return s.iMakeARequestToGotenbergWithTheFollowingHeaders(ctx, method, endpoint, nil) +} + +func (s *scenario) iMakeARequestToGotenbergWithTheFollowingHeaders(ctx context.Context, method, endpoint string, headersTable *godog.Table) error { + if s.gotenbergContainer == nil { + return errors.New("no Gotenberg container") + } + + base, err := containerHttpEndpoint(ctx, s.gotenbergContainer, "3000") + if err != nil { + return fmt.Errorf("get container HTTP endpoint: %w", err) + } + + headers := make(map[string]string) + if headersTable != nil { + for _, row := range headersTable.Rows { + headers[row.Cells[0].Value] = row.Cells[1].Value + } + } + + resp, err := doRequest(method, fmt.Sprintf("%s%s", base, endpoint), headers, nil) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response body: %w", err) + } + + s.resp = httptest.NewRecorder() + s.resp.Code = resp.StatusCode + for key, values := range resp.Header { + for _, v := range values { + s.resp.Header().Add(key, v) + } + } + _, err = s.resp.Body.Write(body) + if err != nil { + return fmt.Errorf("write response body: %w", err) + } + + return nil +} + +func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ctx context.Context, method, endpoint string, dataTable *godog.Table) error { + if s.gotenbergContainer == nil { + return errors.New("no Gotenberg container") + } + + fields := make(map[string]string) + files := make(map[string][]string) + headers := make(map[string]string) + + for _, row := range dataTable.Rows { + name := row.Cells[0].Value + value := row.Cells[1].Value + kind := row.Cells[2].Value + + switch kind { + case "field": + if name == "downloadFrom" || name == "url" || name == "cookies" { + fields[name] = strings.ReplaceAll(value, "%d", fmt.Sprintf("%d", s.hostPort)) + continue + } + fields[name] = value + case "file": + if strings.Contains(value, "teststore") { + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + _, err := os.Stat(dirPath) + if os.IsNotExist(err) { + return fmt.Errorf("directory %q does not exist", dirPath) + } + value = strings.ReplaceAll(value, "teststore", dirPath) + } else { + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get current directory: %w", err) + } + value = fmt.Sprintf("%s/%s", wd, value) + } + files[name] = append(files[name], value) + case "header": + if name == "Gotenberg-Webhook-Url" || name == "Gotenberg-Webhook-Error-Url" { + headers[name] = fmt.Sprintf(value, s.hostPort) + continue + } + headers[name] = value + default: + return fmt.Errorf("unexpected %q %q", kind, value) + } + } + + base, err := containerHttpEndpoint(ctx, s.gotenbergContainer, "3000") + if err != nil { + return fmt.Errorf("get container HTTP endpoint: %w", err) + } + + resp, err := doFormDataRequest(method, fmt.Sprintf("%s%s", base, endpoint), fields, files, headers) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response body: %w", err) + } + + s.resp = httptest.NewRecorder() + s.resp.Code = resp.StatusCode + for key, values := range resp.Header { + for _, v := range values { + s.resp.Header().Add(key, v) + } + } + _, err = s.resp.Body.Write(body) + if err != nil { + return fmt.Errorf("write response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil + } + + cd := resp.Header.Get("Content-Disposition") + if cd == "" { + return nil + } + + _, params, err := mime.ParseMediaType(cd) + if err != nil { + return fmt.Errorf("parse Content-Disposition header: %w", err) + } + + filename, ok := params["filename"] + if !ok { + return errors.New("no filename in Content-Disposition header") + } + + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + err = os.MkdirAll(dirPath, 0o755) + if err != nil { + return fmt.Errorf("create working directory: %w", err) + } + + fpath := fmt.Sprintf("%s/%s", dirPath, filename) + file, err := os.Create(fpath) + if err != nil { + return fmt.Errorf("create file %q: %w", fpath, err) + } + defer file.Close() + + _, err = file.Write(body) + if err != nil { + return fmt.Errorf("write file %q: %w", fpath, err) + } + + if resp.Header.Get("Content-Type") == "application/zip" { + var format archives.Zip + err = format.Extract(ctx, file, func(ctx context.Context, f archives.FileInfo) error { + source, err := f.Open() + if err != nil { + return fmt.Errorf("open file %q: %w", f.Name(), err) + } + defer source.Close() + + targetPath := fmt.Sprintf("%s/%s", dirPath, f.Name()) + target, err := os.Create(targetPath) + if err != nil { + return fmt.Errorf("create file %q: %w", targetPath, err) + } + defer target.Close() + + _, err = io.Copy(target, source) + if err != nil { + return fmt.Errorf("copy file %q: %w", targetPath, err) + } + + return nil + }) + if err != nil { + return err + } + } + + return nil +} + +func (s *scenario) iWaitForTheAsynchronousRequestToWebhook(ctx context.Context) error { + if s.server == nil { + return errors.New("server not initialized") + } + if s.server.req != nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-s.server.errChan: + return err + } +} + +func (s *scenario) theGotenbergContainerShouldLogTheFollowingEntries(ctx context.Context, should string, entriesTable *godog.Table) error { + if s.gotenbergContainer == nil { + return errors.New("no Gotenberg container") + } + + expected := make([]string, len(entriesTable.Rows)) + for i, row := range entriesTable.Rows { + expected[i] = row.Cells[0].Value + } + + invert := should == "should NOT" + check := func() error { + logs, err := containerLogEntries(ctx, s.gotenbergContainer) + if err != nil { + return fmt.Errorf("get log entries: %w", err) + } + + for _, entry := range expected { + if !invert && !strings.Contains(logs, entry) { + return fmt.Errorf("expected log entry %q not found in %q", expected, logs) + } + + if invert && strings.Contains(logs, entry) { + return fmt.Errorf("log entry %q NOT expected", expected) + } + } + + return nil + } + + var err error + for i := 0; i < 3; i++ { + err = check() + if err != nil && !invert { + // We have to retry as not all logs may have been produced. + time.Sleep(500 * time.Millisecond) + continue + } + break + } + return err +} + +func (s *scenario) theResponseStatusCodeShouldBe(expected int) error { + if expected != s.resp.Code { + return fmt.Errorf("expected response status code to be: %d, but actual is: %d %q", expected, s.resp.Code, s.resp.Body.String()) + } + return nil +} + +func (s *scenario) theHeaderValueShouldBe(kind, name string, expected string) error { + var actual string + if kind == "response" { + actual = s.resp.Header().Get(name) + } else if s.server == nil { + return errors.New("server not initialized") + } else if s.server.req == nil { + return errors.New("no webhook request found") + } else { + actual = s.server.req.Header.Get(name) + } + + if expected != actual { + return fmt.Errorf("expected %s header %q to be: %q, but actual is: %q", kind, name, expected, actual) + } + return nil +} + +func (s *scenario) theCookieValueShouldBe(kind, name, expected string) error { + var cookies []*http.Cookie + if kind == "response" { + cookies = s.resp.Result().Cookies() + } else if s.server == nil { + return errors.New("server not initialized") + } else if s.server.req == nil { + return errors.New("no webhook request found") + } else { + cookies = s.server.req.Cookies() + } + + var actual *http.Cookie + for _, cookie := range cookies { + if cookie.Name == name { + actual = cookie + break + } + } + + if actual == nil { + if expected != "" { + return fmt.Errorf("expected %s cookie %q not found", kind, name) + } + return nil + } + + if expected != actual.Value { + return fmt.Errorf("expected %s cookie %q to be: %q, but actual is: %q", kind, name, expected, actual.Value) + } + + return nil +} + +func (s *scenario) theBodyShouldMatchString(kind string, expectedDoc *godog.DocString) error { + var actual string + if kind == "response" { + actual = s.resp.Body.String() + } else if s.server == nil { + return errors.New("server not initialized") + } else if s.server.req == nil { + return errors.New("no webhook request found") + } else { + actual = string(s.server.bodyCopy) + } + + expected := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion) + + if actual != expected { + return fmt.Errorf("expected %q body to be: %q, but actual is: %q", kind, expected, actual) + } + return nil +} + +func (s *scenario) theBodyShouldContainString(kind string, expectedDoc *godog.DocString) error { + var actual string + if kind == "response" { + actual = s.resp.Body.String() + } else if s.server == nil { + return errors.New("server not initialized") + } else if s.server.req == nil { + return errors.New("no webhook request found") + } else { + actual = string(s.server.bodyCopy) + } + + expected := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion) + + if !strings.Contains(actual, expected) { + return fmt.Errorf("expected %q body to contain: %q, but actual is: %q", kind, expected, actual) + } + return nil +} + +func (s *scenario) theBodyShouldMatchJSON(kind string, expectedDoc *godog.DocString) error { + var body []byte + if kind == "response" { + body = s.resp.Body.Bytes() + } else if s.server == nil { + return errors.New("server not initialized") + } else if s.server.req == nil { + return errors.New("no webhook request found") + } else { + body = s.server.bodyCopy + } + + var expected, actual interface{} + + content := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion) + err := json.Unmarshal([]byte(content), &expected) + if err != nil { + return fmt.Errorf("unmarshal expected JSON: %w", err) + } + + err = json.Unmarshal(body, &actual) + if err != nil { + return fmt.Errorf("unmarshal actual JSON: %w", err) + } + + err = compareJson(expected, actual) + if err != nil { + return fmt.Errorf("expected matching JSON: %w", err) + } + + return nil +} + +func (s *scenario) thereShouldBePdfs(expected int, kind string) error { + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + + _, err := os.Stat(dirPath) + if os.IsNotExist(err) { + return fmt.Errorf("directory %q does not exist", dirPath) + } + + var paths []string + err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error { + if pathErr != nil { + return pathErr + } + if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") { + paths = append(paths, path) + } + return nil + }) + if err != nil { + return fmt.Errorf("walk %q: %w", s.workdir, err) + } + + if len(paths) != expected { + return fmt.Errorf("expected %d PDF(s), but actual is %d", expected, len(paths)) + } + + return nil +} + +func (s *scenario) thereShouldBeTheFollowingFiles(kind string, filesTable *godog.Table) error { + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + + _, err := os.Stat(dirPath) + if os.IsNotExist(err) { + return fmt.Errorf("directory %q does not exist", dirPath) + } + + var filenames []string + err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error { + if pathErr != nil { + return pathErr + } + if !info.IsDir() { + filenames = append(filenames, info.Name()) + } + return nil + }) + if err != nil { + return fmt.Errorf("walk %q: %w", s.workdir, err) + } + + for _, row := range filesTable.Rows { + found := false + expected := row.Cells[0].Value + for _, filename := range filenames { + if strings.HasPrefix(expected, "*_") && strings.Contains(filename, strings.ReplaceAll(expected, "*_", "")) { + found = true + } + if strings.EqualFold(expected, filename) { + found = true + break + } + } + if !found { + return fmt.Errorf("expected file %q not found among %q", expected, filenames) + } + } + + return nil +} + +func (s *scenario) thePdfsShouldBeValidWithAToleranceOf(ctx context.Context, kind, validate string, tolerance int) error { + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + + _, err := os.Stat(dirPath) + if os.IsNotExist(err) { + return fmt.Errorf("directory %q does not exist", dirPath) + } + + var paths []string + err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error { + if pathErr != nil { + return pathErr + } + if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") { + paths = append(paths, path) + } + return nil + }) + if err != nil { + return fmt.Errorf("walk %q: %w", s.workdir, err) + } + + var flavor string + switch validate { + case "PDF/A-1b": + flavor = "1b" + case "PDF/A-2b": + flavor = "2b" + case "PDF/A-3b": + flavor = "3b" + case "PDF/UA-1": + flavor = "ua1" + case "PDF/UA-2": + flavor = "ua2" + default: + return fmt.Errorf("unknown %q", validate) + } + + re := regexp.MustCompile(`failedRules="(\d+)"`) + for _, path := range paths { + cmd := []string{ + "verapdf", + "-f", + flavor, + filepath.Base(path), + } + + output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path) + if err != nil { + return fmt.Errorf("exec %q: %w", cmd, err) + } + + matches := re.FindStringSubmatch(output) + if len(matches) < 2 { + return errors.New("expected failed rules") + } + + failedRules, err := strconv.Atoi(matches[1]) + if err != nil { + return fmt.Errorf("convert failed rules value %q to integer: %w", matches[1], err) + } + + if tolerance < failedRules { + return fmt.Errorf("expected failed rules to be inferior or equal to: %d, but actual is %d", tolerance, failedRules) + } + } + + return nil +} + +func (s *scenario) thePdfShouldHavePages(ctx context.Context, name string, pages int) error { + var path string + if !strings.HasPrefix(name, "*_") { + path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name) + + _, err := os.Stat(path) + if os.IsNotExist(err) { + return fmt.Errorf("PDF %q does not exist", path) + } + } else { + substr := strings.ReplaceAll(name, "*_", "") + err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")), func(currentPath string, info os.FileInfo, pathErr error) error { + if pathErr != nil { + return pathErr + } + if strings.Contains(info.Name(), substr) { + path = currentPath + return filepath.SkipDir + } + return nil + }) + if err != nil { + return fmt.Errorf("walk %q: %w", s.workdir, err) + } + } + + cmd := []string{ + "pdfinfo", + filepath.Base(path), + } + + output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path) + if err != nil { + return fmt.Errorf("exec %q: %w", cmd, err) + } + + output = strings.ReplaceAll(output, " ", "") + re := regexp.MustCompile(`Pages:(\d+)`) + matches := re.FindStringSubmatch(output) + + if len(matches) < 2 { + return errors.New("expected pages") + } + + actual, err := strconv.Atoi(matches[1]) + if err != nil { + return fmt.Errorf("convert pages value %q to integer: %w", matches[1], err) + } + + if actual != pages { + return fmt.Errorf("expected %d pages, but actual is %d", pages, actual) + } + + return nil +} + +func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error { + var path string + if !strings.HasPrefix(name, "*_") { + path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name) + + _, err := os.Stat(path) + if os.IsNotExist(err) { + return fmt.Errorf("PDF %q does not exist", path) + } + } else { + substr := strings.ReplaceAll(name, "*_", "") + err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")), func(currentPath string, info os.FileInfo, pathErr error) error { + if pathErr != nil { + return pathErr + } + if strings.Contains(info.Name(), substr) { + path = currentPath + return filepath.SkipDir + } + return nil + }) + if err != nil { + return fmt.Errorf("walk %q: %w", s.workdir, err) + } + } + + cmd := []string{ + "pdfinfo", + filepath.Base(path), + } + + output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path) + if err != nil { + return fmt.Errorf("exec %q: %w", cmd, err) + } + + output = strings.ReplaceAll(output, " ", "") + re := regexp.MustCompile(`Pagesize:(\d+)x(\d+).*`) + matches := re.FindStringSubmatch(output) + + if len(matches) < 3 { + return errors.New("expected page size") + } + + invert := kind == "should NOT" + + width, err := strconv.Atoi(matches[1]) + if err != nil { + return fmt.Errorf("convert width value %q to integer: %w", matches[1], err) + } + + height, err := strconv.Atoi(matches[2]) + if err != nil { + return fmt.Errorf("convert height value %q to integer: %w", matches[2], err) + } + + if invert && height < width { + return fmt.Errorf("expected height %d to be greater than width %d", height, width) + } + + if !invert && width < height { + return fmt.Errorf("expected width %d to be greater than height %d", width, height) + } + + return nil +} + +func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context, name, kind string, page int, expected *godog.DocString) error { + var path string + if !strings.HasPrefix(name, "*_") { + path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name) + + _, err := os.Stat(path) + if os.IsNotExist(err) { + return fmt.Errorf("PDF %q does not exist", path) + } + } else { + substr := strings.ReplaceAll(name, "*_", "") + err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")), func(currentPath string, info os.FileInfo, pathErr error) error { + if pathErr != nil { + return pathErr + } + if strings.Contains(info.Name(), substr) { + path = currentPath + return filepath.SkipDir + } + return nil + }) + if err != nil { + return fmt.Errorf("walk %q: %w", s.workdir, err) + } + } + + cmd := []string{ + "pdftotext", + "-f", + fmt.Sprintf("%d", page), + "-l", + fmt.Sprintf("%d", page), + filepath.Base(path), + "-", + } + + output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path) + if err != nil { + return fmt.Errorf("exec %q: %w", cmd, err) + } + + invert := kind == "should NOT" + + if !invert && !strings.Contains(output, expected.Content) { + return fmt.Errorf("expected %q not found in %q", expected.Content, output) + } + + if invert && strings.Contains(output, expected.Content) { + return fmt.Errorf("%q found in %q", expected.Content, output) + } + + return nil +} + +func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should string) error { + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + + _, err := os.Stat(dirPath) + if os.IsNotExist(err) { + return fmt.Errorf("directory %q does not exist", dirPath) + } + + var paths []string + err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error { + if pathErr != nil { + return pathErr + } + if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") { + paths = append(paths, path) + } + return nil + }) + if err != nil { + return fmt.Errorf("walk %q: %w", s.workdir, err) + } + + invert := should == "should NOT" + + for _, path := range paths { + cmd := []string{ + "verapdf", + "-off", + "--extract", + "annotations", + filepath.Base(path), + } + + output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path) + if err != nil { + return fmt.Errorf("exec %q: %w", cmd, err) + } + + if invert && strings.Contains(output, "") { + return fmt.Errorf("PDF %q is flatten", path) + } + + if !invert && !strings.Contains(output, "") { + return fmt.Errorf("PDF %q is not flatten", path) + } + } + + return nil +} + +func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, should string) error { + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + + _, err := os.Stat(dirPath) + if os.IsNotExist(err) { + return fmt.Errorf("directory %q does not exist", dirPath) + } + + var paths []string + err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error { + if pathErr != nil { + return pathErr + } + if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") { + paths = append(paths, path) + } + return nil + }) + if err != nil { + return fmt.Errorf("walk %q: %w", dirPath, err) + } + + invert := should == "should NOT" + re := regexp.MustCompile(`CommandLineError:Incorrectpassword`) + + for _, path := range paths { + cmd := []string{ + "pdfinfo", + filepath.Base(path), + } + + output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path) + if err != nil { + return fmt.Errorf("exec %q: %w", cmd, err) + } + + output = strings.ReplaceAll(output, " ", "") + output = strings.ReplaceAll(output, "\n", "") + matches := re.FindStringSubmatch(output) + isEncrypted := len(matches) >= 1 && matches[0] == "CommandLineError:Incorrectpassword" + + if invert && isEncrypted { + return fmt.Errorf("PDF %q is encrypted", path) + } + if !invert && !isEncrypted { + return fmt.Errorf("PDF %q is not encrypted: %q", path, output) + } + } + + return nil +} + +func (s *scenario) thePdfsShouldHaveEmbeddedFile(ctx context.Context, kind, should, embed string) error { + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + + _, err := os.Stat(dirPath) + if os.IsNotExist(err) { + return fmt.Errorf("directory %q does not exist", dirPath) + } + + var paths []string + err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error { + if pathErr != nil { + return pathErr + } + if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") { + paths = append(paths, path) + } + return nil + }) + if err != nil { + return fmt.Errorf("walk %q: %w", dirPath, err) + } + + invert := should == "should NOT" + + for _, path := range paths { + cmd := []string{ + "verapdf", + "--off", + "--loglevel", + "0", + "--extract", + "embeddedFile", + filepath.Base(path), + } + + output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path) + if err != nil { + return fmt.Errorf("exec %q: %w", cmd, err) + } + + found := strings.Contains(output, fmt.Sprintf("%s", embed)) + + if invert && found { + return fmt.Errorf("embed %q found", embed) + } + + if !invert && !found { + return fmt.Errorf("embed %q not found", embed) + } + } + + return nil +} + +func InitializeScenario(ctx *godog.ScenarioContext) { + s := &scenario{} + ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) { + wd, err := os.Getwd() + if err != nil { + return ctx, fmt.Errorf("get current directory: %w", err) + } + s.workdir = fmt.Sprintf("%s/teststore/%s", wd, uuid.NewString()) + err = os.MkdirAll(s.workdir, 0o755) + if err != nil { + return ctx, fmt.Errorf("create working directory: %w", err) + } + return ctx, nil + }) + ctx.Given(`^I have a default Gotenberg container$`, s.iHaveADefaultGotenbergContainer) + ctx.Given(`^I have a Gotenberg container with the following environment variable\(s\):$`, s.iHaveAGotenbergContainerWithTheFollowingEnvironmentVariables) + ctx.Given(`^I have a (webhook|static) server$`, s.iHaveAServer) + ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint$`, s.iMakeARequestToGotenberg) + ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint with the following header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingHeaders) + ctx.When(`^I make a "(POST)" request to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders) + ctx.When(`^I wait for the asynchronous request to the webhook$`, s.iWaitForTheAsynchronousRequestToWebhook) + ctx.Then(`^the Gotenberg container (should|should NOT) log the following entries:$`, s.theGotenbergContainerShouldLogTheFollowingEntries) + ctx.Then(`^the response status code should be (\d+)$`, s.theResponseStatusCodeShouldBe) + ctx.Then(`^the (response|webhook request|file request|server request) header "([^"]*)" should be "([^"]*)"$`, s.theHeaderValueShouldBe) + ctx.Then(`^the (response|webhook request|file request|server request) cookie "([^"]*)" should be "([^"]*)"$`, s.theCookieValueShouldBe) + ctx.Then(`^the (response|webhook request) body should match string:$`, s.theBodyShouldMatchString) + ctx.Then(`^the (response|webhook request) body should contain string:$`, s.theBodyShouldContainString) + ctx.Then(`^the (response|webhook request) body should match JSON:$`, s.theBodyShouldMatchJSON) + ctx.Then(`^there should be (\d+) PDF\(s\) in the (response|webhook request)$`, s.thereShouldBePdfs) + ctx.Then(`^there should be the following file\(s\) in the (response|webhook request):$`, s.thereShouldBeTheFollowingFiles) + ctx.Then(`^the (response|webhook request) PDF\(s\) should be valid "([^"]*)" with a tolerance of (\d+) failed rule\(s\)$`, s.thePdfsShouldBeValidWithAToleranceOf) + ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be flatten$`, s.thePdfsShouldBeFlatten) + ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be encrypted`, s.thePdfsShouldBeEncrypted) + ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) have the "([^"]*)" file embedded$`, s.thePdfsShouldHaveEmbeddedFile) + ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages) + ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation) + ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage) + ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) { + if s.gotenbergContainer != nil { + errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0)) + if errTerminate != nil { + return ctx, fmt.Errorf("terminate Gotenberg container: %w", errTerminate) + } + } + if s.gotenbergContainerNetwork != nil { + errRemove := s.gotenbergContainerNetwork.Remove(ctx) + if errRemove != nil { + return ctx, fmt.Errorf("remove Gotenberg container network: %w", errRemove) + } + } + return ctx, nil + }) + ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) { + errReset := s.reset(ctx) + if errReset != nil { + return ctx, fmt.Errorf("reset scenario: %w", errReset) + } + return ctx, nil + }) +} diff --git a/test/integration/scenario/server.go b/test/integration/scenario/server.go new file mode 100644 index 000000000..861d70fb8 --- /dev/null +++ b/test/integration/scenario/server.go @@ -0,0 +1,194 @@ +package scenario + +import ( + "context" + "errors" + "fmt" + "io" + "mime" + "net" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/cucumber/godog" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/mholt/archives" +) + +type server struct { + srv *echo.Echo + req *http.Request + bodyCopy []byte + errChan chan error +} + +func newServer(ctx context.Context, workdir string) (*server, error) { + srv := echo.New() + srv.HideBanner = true + srv.HidePort = true + s := &server{ + srv: srv, + errChan: make(chan error, 1), + } + + wd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("get current directory: %w", err) + } + + webhookErr := func(err error) error { + s.errChan <- err + return err + } + + webhookHandler := func(c echo.Context) error { + s.req = c.Request() + + body, err := io.ReadAll(s.req.Body) + if err != nil { + return webhookErr(fmt.Errorf("read request body: %w", err)) + } + + s.bodyCopy = body + + cd := s.req.Header.Get("Content-Disposition") + if cd == "" { + return webhookErr(fmt.Errorf("no Content-Disposition header")) + } + + _, params, err := mime.ParseMediaType(cd) + if err != nil { + return webhookErr(fmt.Errorf("parse Content-Disposition header: %w", err)) + } + + filename, ok := params["filename"] + if !ok { + filename = uuid.NewString() + contentType := s.req.Header.Get("Content-Type") + switch contentType { + case "application/zip": + filename = fmt.Sprintf("%s.zip", filename) + case "application/pdf": + filename = fmt.Sprintf("%s.pdf", filename) + default: + return webhookErr(errors.New("no filename in Content-Disposition header")) + } + } + + dirPath := fmt.Sprintf("%s/%s", workdir, s.req.Header.Get("Gotenberg-Trace")) + err = os.MkdirAll(dirPath, 0o755) + if err != nil { + return webhookErr(fmt.Errorf("create working directory: %w", err)) + } + + fpath := fmt.Sprintf("%s/%s", dirPath, filename) + file, err := os.Create(fpath) + if err != nil { + return webhookErr(fmt.Errorf("create file %q: %w", fpath, err)) + } + defer file.Close() + + _, err = file.Write(body) + if err != nil { + return webhookErr(fmt.Errorf("write file %q: %w", fpath, err)) + } + + if s.req.Header.Get("Content-Type") == "application/zip" { + var format archives.Zip + err = format.Extract(ctx, file, func(ctx context.Context, f archives.FileInfo) error { + source, err := f.Open() + if err != nil { + return fmt.Errorf("open file %q: %w", f.Name(), err) + } + defer source.Close() + + targetPath := fmt.Sprintf("%s/%s", dirPath, f.Name()) + target, err := os.Create(targetPath) + if err != nil { + return fmt.Errorf("create file %q: %w", targetPath, err) + } + defer target.Close() + + _, err = io.Copy(target, source) + if err != nil { + return fmt.Errorf("copy file %q: %w", targetPath, err) + } + + return nil + }) + if err != nil { + return webhookErr(err) + } + } + + return webhookErr(c.String(http.StatusOK, http.StatusText(http.StatusOK))) + } + webhookErrorHandler := func(c echo.Context) error { + s.req = c.Request() + body, err := io.ReadAll(s.req.Body) + if err != nil { + return webhookErr(fmt.Errorf("read request body: %w", err)) + } + s.bodyCopy = body + return webhookErr(c.String(http.StatusOK, http.StatusText(http.StatusOK))) + } + + srv.POST("/webhook", webhookHandler) + srv.PATCH("/webhook", webhookHandler) + srv.PUT("/webhook", webhookHandler) + srv.POST("/webhook/error", webhookErrorHandler) + srv.PATCH("/webhook/error", webhookErrorHandler) + srv.PUT("/webhook/error", webhookErrorHandler) + srv.GET("/static/:path", func(c echo.Context) error { + s.req = c.Request() + path := c.Param("path") + if strings.Contains(path, "teststore") { + return c.Attachment(fmt.Sprintf("%s/%s/%s", workdir, s.req.Header.Get("Gotenberg-Trace"), filepath.Base(path)), filepath.Base(path)) + } + return c.Attachment(fmt.Sprintf("%s/%s", wd, path), filepath.Base(path)) + }) + srv.GET("/html/:path", func(c echo.Context) error { + s.req = c.Request() + path := fmt.Sprintf("%s/%s", wd, c.Param("path")) + f, err := os.Open(path) + if err != nil { + return c.String(http.StatusInternalServerError, fmt.Sprintf("open file %q: %s", path, err)) + } + defer f.Close() + b, err := io.ReadAll(f) + if err != nil { + return c.String(http.StatusInternalServerError, fmt.Sprintf("read file %q: %s", path, err)) + } + return c.HTML(http.StatusOK, string(b)) + }) + + return s, nil +} + +func (s *server) start(ctx context.Context) (int, error) { + // #nosec + ln, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + return 0, fmt.Errorf("create listener: %w", err) + } + + port := ln.Addr().(*net.TCPAddr).Port + + go func() { + s.srv.Listener = ln + err = s.srv.Start("") + if err != nil && !errors.Is(err, http.ErrServerClosed) { + godog.Log(ctx, err.Error()) + } + }() + + return port, nil +} + +func (s *server) stop(ctx context.Context) error { + close(s.errChan) + return s.srv.Shutdown(ctx) +} diff --git "a/test/integration/testdata/Special_Chars_\303\237.docx" "b/test/integration/testdata/Special_Chars_\303\237.docx" new file mode 100644 index 000000000..1868509c5 Binary files /dev/null and "b/test/integration/testdata/Special_Chars_\303\237.docx" differ diff --git a/test/integration/testdata/embed_1.xml b/test/integration/testdata/embed_1.xml new file mode 100644 index 000000000..acd3c4731 --- /dev/null +++ b/test/integration/testdata/embed_1.xml @@ -0,0 +1,5 @@ + + test 1.1 + test 1.2 + test 1.3 + \ No newline at end of file diff --git a/test/integration/testdata/embed_2.xml b/test/integration/testdata/embed_2.xml new file mode 100644 index 000000000..44ff7b56a --- /dev/null +++ b/test/integration/testdata/embed_2.xml @@ -0,0 +1,5 @@ + + test 2.1 + test 2.2 + test 2.3 + \ No newline at end of file diff --git a/test/integration/testdata/feature-rich-html-remote/index.html b/test/integration/testdata/feature-rich-html-remote/index.html new file mode 100644 index 000000000..edfb30779 --- /dev/null +++ b/test/integration/testdata/feature-rich-html-remote/index.html @@ -0,0 +1,64 @@ + + + + + + + Feature Rich HTML + + + + +

Emulated media type is 'print'.

+

Emulated media type is 'screen'.

+ + + + + + + + + + + + + + diff --git a/test/integration/testdata/feature-rich-html/index.html b/test/integration/testdata/feature-rich-html/index.html new file mode 100644 index 000000000..d68ad3fdd --- /dev/null +++ b/test/integration/testdata/feature-rich-html/index.html @@ -0,0 +1,73 @@ + + + + + + + + + + + Feature Rich HTML + + + + +

Emulated media type is 'print'.

+

Emulated media type is 'screen'.

+ + + + + + + + + + + + + + + diff --git a/test/integration/testdata/feature-rich-markdown/index.html b/test/integration/testdata/feature-rich-markdown/index.html new file mode 100644 index 000000000..76a73c28b --- /dev/null +++ b/test/integration/testdata/feature-rich-markdown/index.html @@ -0,0 +1,74 @@ + + + + + + + + + + + Feature Rich HTML + + + + {{ toHTML "table.md" }} + +

Emulated media type is 'print'.

+

Emulated media type is 'screen'.

+ + + + + + + + + + + + + + + diff --git a/test/integration/testdata/feature-rich-markdown/table.md b/test/integration/testdata/feature-rich-markdown/table.md new file mode 100644 index 000000000..bf2ab2f79 --- /dev/null +++ b/test/integration/testdata/feature-rich-markdown/table.md @@ -0,0 +1,7 @@ +## This paragraph displays a table from a markdown file + +| Tables | Are | Cool | +| -------- | :-----------: | ----: | +| col 1 is | left-aligned | $1600 | +| col 2 is | centered | $12 | +| col 3 is | right-aligned | $1 | diff --git a/test/integration/testdata/header-footer-html/footer.html b/test/integration/testdata/header-footer-html/footer.html new file mode 100644 index 000000000..41185251e --- /dev/null +++ b/test/integration/testdata/header-footer-html/footer.html @@ -0,0 +1,13 @@ + + + + + +

of

+ + diff --git a/test/integration/testdata/header-footer-html/header.html b/test/integration/testdata/header-footer-html/header.html new file mode 100644 index 000000000..f9501f916 --- /dev/null +++ b/test/integration/testdata/header-footer-html/header.html @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/test/integration/testdata/page-1-html/index.html b/test/integration/testdata/page-1-html/index.html new file mode 100644 index 000000000..9fd123da8 --- /dev/null +++ b/test/integration/testdata/page-1-html/index.html @@ -0,0 +1,9 @@ + + + + Page 1 + + +

Page 1

+ + diff --git a/test/integration/testdata/page-1-markdown/index.html b/test/integration/testdata/page-1-markdown/index.html new file mode 100644 index 000000000..d2b7442b9 --- /dev/null +++ b/test/integration/testdata/page-1-markdown/index.html @@ -0,0 +1,9 @@ + + + + Page 1 + + + {{ toHTML "page_1.md" }} + + diff --git a/test/integration/testdata/page-1-markdown/page_1.md b/test/integration/testdata/page-1-markdown/page_1.md new file mode 100644 index 000000000..960800143 --- /dev/null +++ b/test/integration/testdata/page-1-markdown/page_1.md @@ -0,0 +1 @@ +# Page 1 diff --git a/test/integration/testdata/page_1.docx b/test/integration/testdata/page_1.docx new file mode 100644 index 000000000..1868509c5 Binary files /dev/null and b/test/integration/testdata/page_1.docx differ diff --git a/test/integration/testdata/page_1.pdf b/test/integration/testdata/page_1.pdf new file mode 100644 index 000000000..c41ff06d2 Binary files /dev/null and b/test/integration/testdata/page_1.pdf differ diff --git a/test/integration/testdata/page_2.docx b/test/integration/testdata/page_2.docx new file mode 100644 index 000000000..4d4b64de7 Binary files /dev/null and b/test/integration/testdata/page_2.docx differ diff --git a/test/integration/testdata/page_2.pdf b/test/integration/testdata/page_2.pdf new file mode 100644 index 000000000..b8e18570a Binary files /dev/null and b/test/integration/testdata/page_2.pdf differ diff --git a/test/integration/testdata/pages-12-html/index.html b/test/integration/testdata/pages-12-html/index.html new file mode 100644 index 000000000..6db566290 --- /dev/null +++ b/test/integration/testdata/pages-12-html/index.html @@ -0,0 +1,25 @@ + + + + Pages 12 + + + +

Page 1

+

Page 2

+

Page 3

+

Page 4

+

Page 5

+

Page 6

+

Page 7

+

Page 8

+

Page 9

+

Page 10

+

Page 11

+

Page 12

+ + diff --git a/test/integration/testdata/pages-12-markdown/index.html b/test/integration/testdata/pages-12-markdown/index.html new file mode 100644 index 000000000..79b9c6d27 --- /dev/null +++ b/test/integration/testdata/pages-12-markdown/index.html @@ -0,0 +1,25 @@ + + + + Pages 12 + + + +
{{ toHTML "page_1.md" }}
+
{{ toHTML "page_2.md" }}
+
{{ toHTML "page_3.md" }}
+
{{ toHTML "page_4.md" }}
+
{{ toHTML "page_5.md" }}
+
{{ toHTML "page_6.md" }}
+
{{ toHTML "page_7.md" }}
+
{{ toHTML "page_8.md" }}
+
{{ toHTML "page_9.md" }}
+
{{ toHTML "page_10.md" }}
+
{{ toHTML "page_11.md" }}
+
{{ toHTML "page_12.md" }}
+ + diff --git a/test/integration/testdata/pages-12-markdown/page_1.md b/test/integration/testdata/pages-12-markdown/page_1.md new file mode 100644 index 000000000..960800143 --- /dev/null +++ b/test/integration/testdata/pages-12-markdown/page_1.md @@ -0,0 +1 @@ +# Page 1 diff --git a/test/integration/testdata/pages-12-markdown/page_10.md b/test/integration/testdata/pages-12-markdown/page_10.md new file mode 100644 index 000000000..50459fd66 --- /dev/null +++ b/test/integration/testdata/pages-12-markdown/page_10.md @@ -0,0 +1 @@ +# Page 10 diff --git a/test/integration/testdata/pages-12-markdown/page_11.md b/test/integration/testdata/pages-12-markdown/page_11.md new file mode 100644 index 000000000..fad63c84f --- /dev/null +++ b/test/integration/testdata/pages-12-markdown/page_11.md @@ -0,0 +1 @@ +# Page 11 diff --git a/test/integration/testdata/pages-12-markdown/page_12.md b/test/integration/testdata/pages-12-markdown/page_12.md new file mode 100644 index 000000000..cfe1ccf87 --- /dev/null +++ b/test/integration/testdata/pages-12-markdown/page_12.md @@ -0,0 +1 @@ +# Page 12 diff --git a/test/integration/testdata/pages-12-markdown/page_2.md b/test/integration/testdata/pages-12-markdown/page_2.md new file mode 100644 index 000000000..f310be332 --- /dev/null +++ b/test/integration/testdata/pages-12-markdown/page_2.md @@ -0,0 +1 @@ +# Page 2 diff --git a/test/integration/testdata/pages-12-markdown/page_3.md b/test/integration/testdata/pages-12-markdown/page_3.md new file mode 100644 index 000000000..294d95c1b --- /dev/null +++ b/test/integration/testdata/pages-12-markdown/page_3.md @@ -0,0 +1 @@ +# Page 3 diff --git a/test/integration/testdata/pages-12-markdown/page_4.md b/test/integration/testdata/pages-12-markdown/page_4.md new file mode 100644 index 000000000..7caf305d2 --- /dev/null +++ b/test/integration/testdata/pages-12-markdown/page_4.md @@ -0,0 +1 @@ +# Page 4 diff --git a/test/integration/testdata/pages-12-markdown/page_5.md b/test/integration/testdata/pages-12-markdown/page_5.md new file mode 100644 index 000000000..eb51736e5 --- /dev/null +++ b/test/integration/testdata/pages-12-markdown/page_5.md @@ -0,0 +1 @@ +# Page 5 diff --git a/test/integration/testdata/pages-12-markdown/page_6.md b/test/integration/testdata/pages-12-markdown/page_6.md new file mode 100644 index 000000000..74deae7e1 --- /dev/null +++ b/test/integration/testdata/pages-12-markdown/page_6.md @@ -0,0 +1 @@ +# Page 6 diff --git a/test/integration/testdata/pages-12-markdown/page_7.md b/test/integration/testdata/pages-12-markdown/page_7.md new file mode 100644 index 000000000..8bc5eeaf0 --- /dev/null +++ b/test/integration/testdata/pages-12-markdown/page_7.md @@ -0,0 +1 @@ +# Page 7 diff --git a/test/integration/testdata/pages-12-markdown/page_8.md b/test/integration/testdata/pages-12-markdown/page_8.md new file mode 100644 index 000000000..32ea6ab3c --- /dev/null +++ b/test/integration/testdata/pages-12-markdown/page_8.md @@ -0,0 +1 @@ +# Page 8 diff --git a/test/integration/testdata/pages-12-markdown/page_9.md b/test/integration/testdata/pages-12-markdown/page_9.md new file mode 100644 index 000000000..6288d6f81 --- /dev/null +++ b/test/integration/testdata/pages-12-markdown/page_9.md @@ -0,0 +1 @@ +# Page 9 diff --git a/test/integration/testdata/pages-3-html/index.html b/test/integration/testdata/pages-3-html/index.html new file mode 100644 index 000000000..ab0e1d832 --- /dev/null +++ b/test/integration/testdata/pages-3-html/index.html @@ -0,0 +1,16 @@ + + + + Pages 3 + + + +

Page 1

+

Page 2

+

Page 3

+ + diff --git a/test/integration/testdata/pages-3-markdown/index.html b/test/integration/testdata/pages-3-markdown/index.html new file mode 100644 index 000000000..46ba2fe47 --- /dev/null +++ b/test/integration/testdata/pages-3-markdown/index.html @@ -0,0 +1,16 @@ + + + + Pages 3 + + + +
{{ toHTML "page_1.md" }}
+
{{ toHTML "page_2.md" }}
+
{{ toHTML "page_3.md" }}
+ + diff --git a/test/integration/testdata/pages-3-markdown/page_1.md b/test/integration/testdata/pages-3-markdown/page_1.md new file mode 100644 index 000000000..960800143 --- /dev/null +++ b/test/integration/testdata/pages-3-markdown/page_1.md @@ -0,0 +1 @@ +# Page 1 diff --git a/test/integration/testdata/pages-3-markdown/page_2.md b/test/integration/testdata/pages-3-markdown/page_2.md new file mode 100644 index 000000000..f310be332 --- /dev/null +++ b/test/integration/testdata/pages-3-markdown/page_2.md @@ -0,0 +1 @@ +# Page 2 diff --git a/test/integration/testdata/pages-3-markdown/page_3.md b/test/integration/testdata/pages-3-markdown/page_3.md new file mode 100644 index 000000000..294d95c1b --- /dev/null +++ b/test/integration/testdata/pages-3-markdown/page_3.md @@ -0,0 +1 @@ +# Page 3 diff --git a/test/integration/testdata/pages_12.docx b/test/integration/testdata/pages_12.docx new file mode 100644 index 000000000..6fa6d1272 Binary files /dev/null and b/test/integration/testdata/pages_12.docx differ diff --git a/test/integration/testdata/pages_12.pdf b/test/integration/testdata/pages_12.pdf new file mode 100644 index 000000000..7e7459ff8 Binary files /dev/null and b/test/integration/testdata/pages_12.pdf differ diff --git a/test/integration/testdata/pages_3.docx b/test/integration/testdata/pages_3.docx new file mode 100644 index 000000000..792f530be Binary files /dev/null and b/test/integration/testdata/pages_3.docx differ diff --git a/test/integration/testdata/pages_3.pdf b/test/integration/testdata/pages_3.pdf new file mode 100644 index 000000000..2f5c4c030 Binary files /dev/null and b/test/integration/testdata/pages_3.pdf differ diff --git a/test/testdata/api/README.md b/test/integration/testdata/pem/README.md similarity index 100% rename from test/testdata/api/README.md rename to test/integration/testdata/pem/README.md diff --git a/test/testdata/api/cert.pem b/test/integration/testdata/pem/cert.pem similarity index 100% rename from test/testdata/api/cert.pem rename to test/integration/testdata/pem/cert.pem diff --git a/test/testdata/api/key.pem b/test/integration/testdata/pem/key.pem similarity index 100% rename from test/testdata/api/key.pem rename to test/integration/testdata/pem/key.pem diff --git a/test/integration/testdata/protected_page_1.docx b/test/integration/testdata/protected_page_1.docx new file mode 100644 index 000000000..a7b0deb29 Binary files /dev/null and b/test/integration/testdata/protected_page_1.docx differ diff --git a/test/integration/teststore/.gitkeep b/test/integration/teststore/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/test/testdata/api/sample1.txt b/test/testdata/api/sample1.txt deleted file mode 100644 index 191028156..000000000 --- a/test/testdata/api/sample1.txt +++ /dev/null @@ -1 +0,0 @@ -foo \ No newline at end of file diff --git a/test/testdata/api/sample2.pdf b/test/testdata/api/sample2.pdf deleted file mode 100644 index 0a0b284fa..000000000 Binary files a/test/testdata/api/sample2.pdf and /dev/null differ diff --git a/test/testdata/chromium/html/font.woff b/test/testdata/chromium/html/font.woff deleted file mode 100644 index ebd62d592..000000000 Binary files a/test/testdata/chromium/html/font.woff and /dev/null differ diff --git a/test/testdata/chromium/html/footer.html b/test/testdata/chromium/html/footer.html deleted file mode 100644 index c355f721b..000000000 --- a/test/testdata/chromium/html/footer.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - -

- of -

- - \ No newline at end of file diff --git a/test/testdata/chromium/html/header.html b/test/testdata/chromium/html/header.html deleted file mode 100644 index 213ec4fc7..000000000 --- a/test/testdata/chromium/html/header.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/test/testdata/chromium/html/img.gif b/test/testdata/chromium/html/img.gif deleted file mode 100644 index 6b066b53e..000000000 Binary files a/test/testdata/chromium/html/img.gif and /dev/null differ diff --git a/test/testdata/chromium/html/index.html b/test/testdata/chromium/html/index.html deleted file mode 100644 index a19f166e1..000000000 --- a/test/testdata/chromium/html/index.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - Gutenberg - - - -
-
-

Gutenberg

- An image -
- -
-

It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.

- -
-
- -
-

This paragraph uses the default font

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

- -

This paragraph uses a Google font

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

- -

This paragraph uses a local font

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

-
- -
-

This image is loaded from a URL

- -
- -
-

This paragraph appears if wait delay > 2 seconds or if expression window.globalVar === 'ready' returns true

- - -

This paragraph appears if the emulated media type is 'print'

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

- -

This paragraph appears if the emulated media type is 'screen'

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

-
- -
-

This paragraph appears if JavaScript is NOT disabled

- -
- -
-

/etc/passwd

- - -

\\localhost/etc/passwd

- -
- - - - - - - - - - - - \ No newline at end of file diff --git a/test/testdata/chromium/html/style.css b/test/testdata/chromium/html/style.css deleted file mode 100644 index a32c9a714..000000000 --- a/test/testdata/chromium/html/style.css +++ /dev/null @@ -1,29 +0,0 @@ -body { - font-family: Arial, Helvetica, sans-serif; -} - -.center { - text-align: center; -} - -.google-font { - font-family: 'Montserrat', sans-serif; -} - -@font-face { - font-family: 'Local'; - src: url('font.woff') format('woff'); - font-weight: normal; - font-style: normal; -} - -.local-font { - font-family: 'Local' -} - -@media print { - .page-break-after { - page-break-after: always; - } -} - diff --git a/test/testdata/chromium/markdown/defaultfont.md b/test/testdata/chromium/markdown/defaultfont.md deleted file mode 100644 index 14c830357..000000000 --- a/test/testdata/chromium/markdown/defaultfont.md +++ /dev/null @@ -1,3 +0,0 @@ -## This paragraph uses the default font and has been generated from a markdown file - -Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. \ No newline at end of file diff --git a/test/testdata/chromium/markdown/font.woff b/test/testdata/chromium/markdown/font.woff deleted file mode 100644 index ebd62d592..000000000 Binary files a/test/testdata/chromium/markdown/font.woff and /dev/null differ diff --git a/test/testdata/chromium/markdown/footer.html b/test/testdata/chromium/markdown/footer.html deleted file mode 100644 index c355f721b..000000000 --- a/test/testdata/chromium/markdown/footer.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - -

- of -

- - \ No newline at end of file diff --git a/test/testdata/chromium/markdown/googlefont.md b/test/testdata/chromium/markdown/googlefont.md deleted file mode 100644 index d8dd4f23c..000000000 --- a/test/testdata/chromium/markdown/googlefont.md +++ /dev/null @@ -1,3 +0,0 @@ -## This paragraph uses a Google font and has been generated from a markdown file - -Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. \ No newline at end of file diff --git a/test/testdata/chromium/markdown/header.html b/test/testdata/chromium/markdown/header.html deleted file mode 100644 index 213ec4fc7..000000000 --- a/test/testdata/chromium/markdown/header.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/test/testdata/chromium/markdown/img.gif b/test/testdata/chromium/markdown/img.gif deleted file mode 100644 index 6b066b53e..000000000 Binary files a/test/testdata/chromium/markdown/img.gif and /dev/null differ diff --git a/test/testdata/chromium/markdown/index.html b/test/testdata/chromium/markdown/index.html deleted file mode 100644 index faa522dbc..000000000 --- a/test/testdata/chromium/markdown/index.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - Gutenberg - - -
-
-

Gutenberg

- -
- -
-

It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.

- -
-
- -
- {{ toHTML "defaultfont.md" }} - -
- {{ toHTML "googlefont.md" }} -
- -
- {{ toHTML "localfont.md" }} -
-
- -
- {{ toHTML "table.md" }} - -

HTML from previous table

- -
- - \ No newline at end of file diff --git a/test/testdata/chromium/markdown/localfont.md b/test/testdata/chromium/markdown/localfont.md deleted file mode 100644 index c0aac6f33..000000000 --- a/test/testdata/chromium/markdown/localfont.md +++ /dev/null @@ -1,3 +0,0 @@ -## This paragraph uses a local font and has been generated from a markdown file - -Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. \ No newline at end of file diff --git a/test/testdata/chromium/markdown/style.css b/test/testdata/chromium/markdown/style.css deleted file mode 100644 index e937998f4..000000000 --- a/test/testdata/chromium/markdown/style.css +++ /dev/null @@ -1,28 +0,0 @@ -body { - font-family: Arial, Helvetica, sans-serif; -} - -.center { - text-align: center; -} - -.google-font { - font-family: 'Montserrat', sans-serif; -} - -@font-face { - font-family: 'Local'; - src: url('font.woff') format('woff'); - font-weight: normal; - font-style: normal; -} - -.local-font { - font-family: 'Local' -} - -@media print { - .page-break-after { - page-break-after: always; - } -} \ No newline at end of file diff --git a/test/testdata/chromium/markdown/table.md b/test/testdata/chromium/markdown/table.md deleted file mode 100644 index 057f69b3d..000000000 --- a/test/testdata/chromium/markdown/table.md +++ /dev/null @@ -1,7 +0,0 @@ -## This paragraph displays a table from a markdown file - -| Tables | Are | Cool | -|----------|:-------------:|------:| -| col 1 is | left-aligned | $1600 | -| col 2 is | centered | $12 | -| col 3 is | right-aligned | $1 | \ No newline at end of file diff --git a/test/testdata/libreoffice/document.docx b/test/testdata/libreoffice/document.docx deleted file mode 100644 index e745b3e5e..000000000 Binary files a/test/testdata/libreoffice/document.docx and /dev/null differ diff --git a/test/testdata/libreoffice/protected.docx b/test/testdata/libreoffice/protected.docx deleted file mode 100644 index 840d00d56..000000000 Binary files a/test/testdata/libreoffice/protected.docx and /dev/null differ diff --git a/test/testdata/pdfengines/sample1.pdf b/test/testdata/pdfengines/sample1.pdf deleted file mode 100644 index 0a0b284fa..000000000 Binary files a/test/testdata/pdfengines/sample1.pdf and /dev/null differ diff --git a/test/testdata/pdfengines/sample2.pdf b/test/testdata/pdfengines/sample2.pdf deleted file mode 100644 index 0a0b284fa..000000000 Binary files a/test/testdata/pdfengines/sample2.pdf and /dev/null differ