Bladeren bron

http actions: add multipart support

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 2 jaren geleden
bovenliggende
commit
c2a65a9a74

+ 8 - 2
.github/workflows/development.yml

@@ -423,6 +423,12 @@ jobs:
           gzip output/man/man1/*
           gzip output/man/man1/*
           cp sftpgo output/
           cp sftpgo output/
 
 
+      - name: Get commit SHA
+        if: ${{ matrix.arch != 'amd64' }}
+        id: get_commit
+        run: echo ::set-output name=COMMIT::${GITHUB_SHA::8}
+        shell: bash
+
       - uses: uraimo/run-on-arch-action@v2
       - uses: uraimo/run-on-arch-action@v2
         if: ${{ matrix.arch != 'amd64' }}
         if: ${{ matrix.arch != 'amd64' }}
         name: Build for ${{ matrix.arch }}
         name: Build for ${{ matrix.arch }}
@@ -437,7 +443,7 @@ jobs:
           shell: /bin/bash
           shell: /bin/bash
           install: |
           install: |
             apt-get update -q -y
             apt-get update -q -y
-            apt-get install -q -y curl gcc git
+            apt-get install -q -y curl gcc
             if [ ${{ matrix.go }} == 'latest' ]
             if [ ${{ matrix.go }} == 'latest' ]
             then
             then
               GO_VERSION=$(curl -L https://go.dev/VERSION?m=text)
               GO_VERSION=$(curl -L https://go.dev/VERSION?m=text)
@@ -457,7 +463,7 @@ jobs:
             then
             then
               export GOARM=7
               export GOARM=7
             fi
             fi
-            go build -buildvcs=false -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
+            go build -buildvcs=false -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_commit.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
             mkdir -p output/{init,bash_completion,zsh_completion}
             mkdir -p output/{init,bash_completion,zsh_completion}
             cp sftpgo.json output/
             cp sftpgo.json output/
             cp -r templates output/
             cp -r templates output/

+ 7 - 0
.github/workflows/docker.yml

@@ -95,6 +95,13 @@ jobs:
                 fi
                 fi
                 TAGS="${TAGS},${DOCKER_IMAGE}:distroless"
                 TAGS="${TAGS},${DOCKER_IMAGE}:distroless"
                 TAGS_SLIM="${TAGS_SLIM},${DOCKER_IMAGE}:distroless-slim"
                 TAGS_SLIM="${TAGS_SLIM},${DOCKER_IMAGE}:distroless-slim"
+              elif [[ $DOCKER_PKG == debian-plugins ]]; then
+                if [[ -n $MAJOR && -n $MINOR ]]; then
+                  TAGS="${TAGS},${DOCKER_IMAGE}:${MINOR}-plugins,${DOCKER_IMAGE}:${MAJOR}-plugins"
+                  TAGS_SLIM="${TAGS_SLIM},${DOCKER_IMAGE}:${MINOR}-plugins-slim,${DOCKER_IMAGE}:${MAJOR}-plugins-slim"
+                fi
+                TAGS="${TAGS},${DOCKER_IMAGE}:plugins"
+                TAGS_SLIM="${TAGS_SLIM},${DOCKER_IMAGE}:plugins-slim"
               else
               else
                 if [[ -n $MAJOR && -n $MINOR ]]; then
                 if [[ -n $MAJOR && -n $MINOR ]]; then
                   TAGS="${TAGS},${DOCKER_IMAGE}:${MINOR}-alpine,${DOCKER_IMAGE}:${MAJOR}-alpine"
                   TAGS="${TAGS},${DOCKER_IMAGE}:${MINOR}-alpine,${DOCKER_IMAGE}:${MAJOR}-alpine"

+ 8 - 2
.github/workflows/release.yml

@@ -326,6 +326,12 @@ jobs:
         env:
         env:
           SFTPGO_VERSION: ${{ steps.get_version.outputs.SFTPGO_VERSION }}
           SFTPGO_VERSION: ${{ steps.get_version.outputs.SFTPGO_VERSION }}
 
 
+      - name: Get commit SHA
+        if: ${{ matrix.arch != 'amd64' }}
+        id: get_commit
+        run: echo ::set-output name=COMMIT::${GITHUB_SHA::8}
+        shell: bash
+
       - uses: uraimo/run-on-arch-action@v2
       - uses: uraimo/run-on-arch-action@v2
         if: ${{ matrix.arch != 'amd64' }}
         if: ${{ matrix.arch != 'amd64' }}
         name: Build for ${{ matrix.arch }}
         name: Build for ${{ matrix.arch }}
@@ -340,7 +346,7 @@ jobs:
           shell: /bin/bash
           shell: /bin/bash
           install: |
           install: |
             apt-get update -q -y
             apt-get update -q -y
-            apt-get install -q -y curl gcc git xz-utils
+            apt-get install -q -y curl gcc xz-utils
             GO_DOWNLOAD_ARCH=${{ matrix.go-arch }}
             GO_DOWNLOAD_ARCH=${{ matrix.go-arch }}
             if [ ${{ matrix.arch}} == 'armv7' ]
             if [ ${{ matrix.arch}} == 'armv7' ]
             then
             then
@@ -350,7 +356,7 @@ jobs:
             tar -C /usr/local -xzf go.tar.gz
             tar -C /usr/local -xzf go.tar.gz
           run: |
           run: |
             export PATH=$PATH:/usr/local/go/bin
             export PATH=$PATH:/usr/local/go/bin
-            go build -buildvcs=false -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
+            go build -buildvcs=false -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_commit.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
             mkdir -p output/{init,sqlite,bash_completion,zsh_completion}
             mkdir -p output/{init,sqlite,bash_completion,zsh_completion}
             echo "For documentation please take a look here:" > output/README.txt
             echo "For documentation please take a look here:" > output/README.txt
             echo "" >> output/README.txt
             echo "" >> output/README.txt

+ 6 - 5
docker/README.md

@@ -4,11 +4,12 @@ SFTPGo provides an official Docker image, it is available on both [Docker Hub](h
 
 
 ## Supported tags and respective Dockerfile links
 ## Supported tags and respective Dockerfile links
 
 
-- [v2.3.3, v2.3, v2, latest](https://github.com/drakkan/sftpgo/blob/v2.3.3/Dockerfile)
-- [v2.3.3-alpine, v2.3-alpine, v2-alpine, alpine](https://github.com/drakkan/sftpgo/blob/v2.3.3/Dockerfile.alpine)
-- [v2.3.3-slim, v2.3-slim, v2-slim, slim](https://github.com/drakkan/sftpgo/blob/v2.3.3/Dockerfile)
-- [v2.3.3-alpine-slim, v2.3-alpine-slim, v2-alpine-slim, alpine-slim](https://github.com/drakkan/sftpgo/blob/v2.3.3/Dockerfile.alpine)
-- [v2.3.3-distroless-slim, v2.3-distroless-slim, v2-distroless-slim, distroless-slim](https://github.com/drakkan/sftpgo/blob/v2.3.3/Dockerfile.distroless)
+- [v2.3.4, v2.3, v2, latest](https://github.com/drakkan/sftpgo/blob/v2.3.4/Dockerfile)
+- [v2.3.4-plugins, v2.3-plugins, v2-plugins, plugins](https://github.com/drakkan/sftpgo/blob/v2.3.4/Dockerfile)
+- [v2.3.4-alpine, v2.3-alpine, v2-alpine, alpine](https://github.com/drakkan/sftpgo/blob/v2.3.4/Dockerfile.alpine)
+- [v2.3.4-slim, v2.3-slim, v2-slim, slim](https://github.com/drakkan/sftpgo/blob/v2.3.4/Dockerfile)
+- [v2.3.4-alpine-slim, v2.3-alpine-slim, v2-alpine-slim, alpine-slim](https://github.com/drakkan/sftpgo/blob/v2.3.4/Dockerfile.alpine)
+- [v2.3.4-distroless-slim, v2.3-distroless-slim, v2-distroless-slim, distroless-slim](https://github.com/drakkan/sftpgo/blob/v2.3.4/Dockerfile.distroless)
 - [edge](../Dockerfile)
 - [edge](../Dockerfile)
 - [edge-plugins](../Dockerfile)
 - [edge-plugins](../Dockerfile)
 - [edge-alpine](../Dockerfile.alpine)
 - [edge-alpine](../Dockerfile.alpine)

+ 26 - 27
go.mod

@@ -4,21 +4,21 @@ go 1.19
 
 
 require (
 require (
 	cloud.google.com/go/storage v1.26.0
 	cloud.google.com/go/storage v1.26.0
-	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2
+	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.3
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.4.1
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.4.1
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
 	github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
-	github.com/aws/aws-sdk-go-v2 v1.16.12
-	github.com/aws/aws-sdk-go-v2/config v1.17.3
-	github.com/aws/aws-sdk-go-v2/credentials v1.12.16
-	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.13
-	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.29
-	github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.15
-	github.com/aws/aws-sdk-go-v2/service/s3 v1.27.7
-	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.20
-	github.com/aws/aws-sdk-go-v2/service/sts v1.16.15
+	github.com/aws/aws-sdk-go-v2 v1.16.14
+	github.com/aws/aws-sdk-go-v2/config v1.17.5
+	github.com/aws/aws-sdk-go-v2/credentials v1.12.18
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.15
+	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.31
+	github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.17
+	github.com/aws/aws-sdk-go-v2/service/s3 v1.27.9
+	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.22
+	github.com/aws/aws-sdk-go-v2/service/sts v1.16.17
 	github.com/cockroachdb/cockroach-go/v2 v2.2.15
 	github.com/cockroachdb/cockroach-go/v2 v2.2.15
-	github.com/coreos/go-oidc/v3 v3.2.0
+	github.com/coreos/go-oidc/v3 v3.3.0
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
 	github.com/fclairamb/ftpserverlib v0.19.1
 	github.com/fclairamb/ftpserverlib v0.19.1
 	github.com/fclairamb/go-log v0.4.1
 	github.com/fclairamb/go-log v0.4.1
@@ -44,7 +44,7 @@ require (
 	github.com/minio/sio v0.3.0
 	github.com/minio/sio v0.3.0
 	github.com/otiai10/copy v1.7.0
 	github.com/otiai10/copy v1.7.0
 	github.com/pires/go-proxyproto v0.6.2
 	github.com/pires/go-proxyproto v0.6.2
-	github.com/pkg/sftp v1.13.5
+	github.com/pkg/sftp v1.13.6-0.20220831160757-628507938ec6
 	github.com/pquerna/otp v1.3.0
 	github.com/pquerna/otp v1.3.0
 	github.com/prometheus/client_golang v1.13.0
 	github.com/prometheus/client_golang v1.13.0
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/robfig/cron/v3 v3.0.1
@@ -52,7 +52,7 @@ require (
 	github.com/rs/xid v1.4.0
 	github.com/rs/xid v1.4.0
 	github.com/rs/zerolog v1.28.0
 	github.com/rs/zerolog v1.28.0
 	github.com/sftpgo/sdk v0.1.2-0.20220828084006-f9e2fffac657
 	github.com/sftpgo/sdk v0.1.2-0.20220828084006-f9e2fffac657
-	github.com/shirou/gopsutil/v3 v3.22.7
+	github.com/shirou/gopsutil/v3 v3.22.8
 	github.com/spf13/afero v1.9.2
 	github.com/spf13/afero v1.9.2
 	github.com/spf13/cobra v1.5.0
 	github.com/spf13/cobra v1.5.0
 	github.com/spf13/viper v1.12.0
 	github.com/spf13/viper v1.12.0
@@ -80,18 +80,18 @@ require (
 	cloud.google.com/go/iam v0.3.0 // indirect
 	cloud.google.com/go/iam v0.3.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.1 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.1 // indirect
 	github.com/ajg/form v1.5.1 // indirect
 	github.com/ajg/form v1.5.1 // indirect
-	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.5 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.19 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.13 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/ini v1.3.20 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.10 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.6 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.14 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.13 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.13 // indirect
-	github.com/aws/aws-sdk-go-v2/service/sso v1.11.19 // indirect
-	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.1 // indirect
-	github.com/aws/smithy-go v1.13.0 // indirect
+	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.7 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.21 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.15 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/ini v1.3.22 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.12 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.8 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.16 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.15 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.15 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.11.21 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.3 // indirect
+	github.com/aws/smithy-go v1.13.2 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/boombuler/barcode v1.0.1 // indirect
 	github.com/boombuler/barcode v1.0.1 // indirect
 	github.com/cenkalti/backoff v2.2.1+incompatible // indirect
 	github.com/cenkalti/backoff v2.2.1+incompatible // indirect
@@ -156,7 +156,7 @@ require (
 	golang.org/x/tools v0.1.12 // indirect
 	golang.org/x/tools v0.1.12 // indirect
 	golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
 	golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf // indirect
+	google.golang.org/genproto v0.0.0-20220902135211-223410557253 // indirect
 	google.golang.org/grpc v1.49.0 // indirect
 	google.golang.org/grpc v1.49.0 // indirect
 	google.golang.org/protobuf v1.28.1 // indirect
 	google.golang.org/protobuf v1.28.1 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
@@ -167,7 +167,6 @@ require (
 
 
 replace (
 replace (
 	github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
 	github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
-	github.com/pkg/sftp => github.com/drakkan/sftp v0.0.0-20220716075551-51a5aa4e044d
 	golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20220831070132-e3c36f2ab82b
 	golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20220831070132-e3c36f2ab82b
 	golang.org/x/net => github.com/drakkan/net v0.0.0-20220828084259-1562d1fb0fc5
 	golang.org/x/net => github.com/drakkan/net v0.0.0-20220828084259-1562d1fb0fc5
 )
 )

+ 54 - 53
go.sum

@@ -90,8 +90,8 @@ github.com/Azure/azure-sdk-for-go v51.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo
 github.com/Azure/azure-sdk-for-go v59.3.0+incompatible h1:dPIm0BO4jsMXFcCI/sLTPkBtE7mk8WMuRHA0JeWhlcQ=
 github.com/Azure/azure-sdk-for-go v59.3.0+incompatible h1:dPIm0BO4jsMXFcCI/sLTPkBtE7mk8WMuRHA0JeWhlcQ=
 github.com/Azure/azure-sdk-for-go v59.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
 github.com/Azure/azure-sdk-for-go v59.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
 github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw=
 github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2 h1:lneMk5qtUMulXa/eVxjVd+/bDYMEDIqYpLzLa2/EsNI=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.3 h1:8LoU8N2lIUzkmstvwXvVfniMZlFbesfT2AmA1aqvRr8=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.3/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
 github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0=
 github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0=
 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0 h1:Yoicul8bnVdQrhDMTHxdEckRGX01XvwXDHUT9zYZ3k0=
 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0 h1:Yoicul8bnVdQrhDMTHxdEckRGX01XvwXDHUT9zYZ3k0=
 github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8=
 github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8=
@@ -142,69 +142,69 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
 github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
 github.com/aws/aws-sdk-go-v2 v1.16.2/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU=
 github.com/aws/aws-sdk-go-v2 v1.16.2/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU=
-github.com/aws/aws-sdk-go-v2 v1.16.12 h1:wbMYa2PlFysFx2GLIQojr6FJV5+OWCM/BwyHXARxETA=
-github.com/aws/aws-sdk-go-v2 v1.16.12/go.mod h1:C+Ym0ag2LIghJbXhfXZ0YEEp49rBWowxKzJLUoob0ts=
+github.com/aws/aws-sdk-go-v2 v1.16.14 h1:db6GvO4Z2UqHt5gvT0lr6J5x5P+oQ7bdRzczVaRekMU=
+github.com/aws/aws-sdk-go-v2 v1.16.14/go.mod h1:s/G+UV29dECbF5rf+RNj1xhlmvoNurGSr+McVSRj59w=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.1/go.mod h1:n8Bs1ElDD2wJ9kCRTczA83gYbBmjSwZp3umc6zF4EeM=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.1/go.mod h1:n8Bs1ElDD2wJ9kCRTczA83gYbBmjSwZp3umc6zF4EeM=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.5 h1:7A1nDFvkVlBmMa69QMLkw/m/DDHm6PUluIYK61aQoOY=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.5/go.mod h1:DnlOnWR2YuzMXNSHHNuoklObUE3SwWlcRTGL/zL+Aj8=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.7 h1:/kxQjtZc7j67TMW/aFJfpsrlvFhsq3lNbX41qN5Tro4=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.7/go.mod h1:KvHyNlxCjo9Y1Fsz+6Ex9OaN2jKijvMxzROxpW5Vctc=
 github.com/aws/aws-sdk-go-v2/config v1.15.3/go.mod h1:9YL3v07Xc/ohTsxFXzan9ZpFpdTOFl4X65BAKYaz8jg=
 github.com/aws/aws-sdk-go-v2/config v1.15.3/go.mod h1:9YL3v07Xc/ohTsxFXzan9ZpFpdTOFl4X65BAKYaz8jg=
-github.com/aws/aws-sdk-go-v2/config v1.17.3 h1:s1As/fiVMmM3CObC4GcSaSbkhm88S6a5qn8St3wgal0=
-github.com/aws/aws-sdk-go-v2/config v1.17.3/go.mod h1:tRGUOfk9Rrf6UCJm5qDlL9AizSsgvteuKX4qajAV3pU=
+github.com/aws/aws-sdk-go-v2/config v1.17.5 h1:+NS1BWvprx7nHcIk5o32LrZgifs/7Pm1V2nWjQgZ2H0=
+github.com/aws/aws-sdk-go-v2/config v1.17.5/go.mod h1:H0cvPNDO3uExWts/9PDhD/0ne2esu1uaIulwn1vkwxM=
 github.com/aws/aws-sdk-go-v2/credentials v1.11.2/go.mod h1:j8YsY9TXTm31k4eFhspiQicfXPLZ0gYXA50i4gxPE8g=
 github.com/aws/aws-sdk-go-v2/credentials v1.11.2/go.mod h1:j8YsY9TXTm31k4eFhspiQicfXPLZ0gYXA50i4gxPE8g=
-github.com/aws/aws-sdk-go-v2/credentials v1.12.16 h1:HXczS88Pg36j8dq0KSjtHBPFs8gdRyBSS1hueeG/rxA=
-github.com/aws/aws-sdk-go-v2/credentials v1.12.16/go.mod h1:eLJ+j1lwQdHJ0c56tRoDWcgss1e/laVmvW2AaOicuAw=
+github.com/aws/aws-sdk-go-v2/credentials v1.12.18 h1:HF62tbhARhgLfvmfwUbL9qZ+dkbZYzbFdxBb3l5gr7Q=
+github.com/aws/aws-sdk-go-v2/credentials v1.12.18/go.mod h1:O7n/CPagQ33rfG6h7vR/W02ammuc5CrsSM22cNZp9so=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.3/go.mod h1:uk1vhHHERfSVCUnqSqz8O48LBYDSC+k6brng09jcMOk=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.3/go.mod h1:uk1vhHHERfSVCUnqSqz8O48LBYDSC+k6brng09jcMOk=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.13 h1:+uferi8SUDZtMloCDt24Zenyy/i71C/ua5mjUCpbpN0=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.13/go.mod h1:y0eXmsNBFIVjUE8ZBjES8myOHlMsXDz7qGT93+MVdjk=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.15 h1:nkQ+aI0OCeYfzrBipL6ja/6VEbUnHQoZHBHtoK+Nzxw=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.15/go.mod h1:Oz2/qWINxIgSmoZT9adpxJy2UhpcOAI3TIyWgYMVSz0=
 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.3/go.mod h1:0dHuD2HZZSiwfJSy1FO5bX1hQ1TxVV1QXXjpn3XUE44=
 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.3/go.mod h1:0dHuD2HZZSiwfJSy1FO5bX1hQ1TxVV1QXXjpn3XUE44=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.29 h1:VKi/79iKGaZ9pJTSuj/gNlzJdFczcGcsw9NDAT7I+hY=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.29/go.mod h1:ge60sLiMug/7ubLIbRyM9zNv5fR99ZzR+staDaM7+Tw=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.31 h1:Ggf7rvFS1s3/Nauv2mokAY+RfKsCAHvfiiZJoYd0lV0=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.31/go.mod h1:Iv2xOFdy8aFIxVKEdzo9puLXFaGNnjx5xzGYIlGzhuY=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9/go.mod h1:AnVH5pvai0pAF4lXRq0bmhbes1u9R8wTE+g+183bZNM=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9/go.mod h1:AnVH5pvai0pAF4lXRq0bmhbes1u9R8wTE+g+183bZNM=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.19 h1:gC5mudiFrWGhzcdoWj1iCGUfrzCpQG0MQIQf0CXFFQQ=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.19/go.mod h1:llxE6bwUZhuCas0K7qGiu5OgMis3N7kdWtFSxoHmJ7E=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.21 h1:gRIXnmAVNyoRQywdNtpAkgY+f30QNzgF53Q5OobNZZs=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.21/go.mod h1:XsmHMV9c512xgsW01q7H0ut+UQQQpWX8QsFbdLHDwaU=
 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.3/go.mod h1:ssOhaLpRlh88H3UmEcsBoVKq309quMvm3Ds8e9d4eJM=
 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.3/go.mod h1:ssOhaLpRlh88H3UmEcsBoVKq309quMvm3Ds8e9d4eJM=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.13 h1:qezY57na06d6kSE7uuB0N7XEflu914AXx/hg2L8Ykcw=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.13/go.mod h1:lB12mkZqCSo5PsdBFLNqc2M/OOYgNAy8UtaktyuWvE8=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.15 h1:noAhOo2mMDyYhTx99aYPvQw16T3fQ/DiKAv9fzpIKH8=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.15/go.mod h1:kjJ4CyD9M3Wq88GYg3IPfj67Rs0Uvz8aXK7MJ8BvE4I=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.3.10/go.mod h1:8DcYQcz0+ZJaSxANlHIsbbi6S+zMwjwdDqwW3r9AzaE=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.3.10/go.mod h1:8DcYQcz0+ZJaSxANlHIsbbi6S+zMwjwdDqwW3r9AzaE=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.3.20 h1:GvszACAU8GSV3+Tant5GutW6smY8WavrP8ZuRS9Ku4Q=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.3.20/go.mod h1:bfTcsThj5a9P5pIGRy0QudJ8k4+issxXX+O6Djnd5Cs=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.10 h1:233xgzn4lsBeN7qgG+k2kLquzBk35WB+nIhPMeK0h/Q=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.10/go.mod h1:1nl/nuVB6+UOpiyYJBfyhCzsX8fJAL6fCVcbtPIIV4w=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.3.22 h1:nF+E8HfYpOMw6M5oA9efB602VC00IHNQnB5CmFvZPvA=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.3.22/go.mod h1:tltHVGy977LrSOgRR5aV9+miyno/Gul/uJNPKS7FzP4=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.12 h1:i0Tig01XGhXo/ki1BZUbRMhusGVCScEvaWdlFRWxAKk=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.12/go.mod h1:QPoxYMISvteeDH4A89gGWWlCA/Bz6oUDF7hGdPdOPuE=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.1/go.mod h1:GeUru+8VzrTXV/83XyMJ80KpH8xO89VPoUileyNQ+tc=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.1/go.mod h1:GeUru+8VzrTXV/83XyMJ80KpH8xO89VPoUileyNQ+tc=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.6 h1:Z0Yw2qkgPZVGbOR70snGRAlBR0QIGPLkHoNhR4+7hbY=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.6/go.mod h1:Slj62rcu4BKdMAH0wqeP0fUkW1b1bkCxcSP+ZY5cevE=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.8 h1:NpixDFjwr1BZg2459mX07NZnVYGGp62Lb6AtVGOLNlo=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.8/go.mod h1:MJUgrBPfGB4yk2uWoImVqd9cklry1hATyJV/7gJ6JTk=
 github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.3/go.mod h1:Seb8KNmD6kVTjwRjVEgOT5hPin6sq+v4C2ycJQDwuH8=
 github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.3/go.mod h1:Seb8KNmD6kVTjwRjVEgOT5hPin6sq+v4C2ycJQDwuH8=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.14 h1:NWR21daQBDyY4WChz4Gd78QuCPorUJiSHg7r1OWvfgA=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.14/go.mod h1:Yz4G3rD1LtBcg6gIYtJtpoEjts9IZMHiamdm3F1xtNA=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.16 h1:kHc3TqW5kJ9Vfd9YEwywrNrL87DItpvAohlP+OuzABY=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.16/go.mod h1:U/9ZCgIx6x6NTdFRt60qO3gxUxBx4gRi+S/Yc/n+7vc=
 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.3/go.mod h1:wlY6SVjuwvh3TVRpTqdy4I1JpBFLX4UGeKZdWntaocw=
 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.3/go.mod h1:wlY6SVjuwvh3TVRpTqdy4I1JpBFLX4UGeKZdWntaocw=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.13 h1:ObfthqDyhe7rMAOa7pqft6974VHIk8BAJB7kYdoIfTA=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.13/go.mod h1:V390DK4MQxLpDdXxFqizyz8KUxuWImkW/xzgXMz0yyk=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.15 h1:xlf0J6DUgAj/ocvKQxCmad8Bu1lJuRbt5Wu+4G1xw1g=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.15/go.mod h1:ZVJ7ejRl4+tkWMuCwjXoy0jd8fF5u3RCyWjSVjUIvQE=
 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.3/go.mod h1:Bm/v2IaN6rZ+Op7zX+bOUMdL4fsrYZiD0dsjLhNKwZc=
 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.3/go.mod h1:Bm/v2IaN6rZ+Op7zX+bOUMdL4fsrYZiD0dsjLhNKwZc=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.13 h1:h1equp9qdWANft5cmtDUditRlALvE7tuaHs2RdSbsQg=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.13/go.mod h1:3RA7cs1uHkbV3f6tMYy7u0OfkyVckZBM70wUS4h1MDk=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.15 h1:v9f7NY7D19ssE2EM+m9yT1m5zdWHuRAsZaFh24GAkOk=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.15/go.mod h1:gXfPo3nMoCbJKTZKDxv3rUhcYJjYT/K++jEqcWHjD/Q=
 github.com/aws/aws-sdk-go-v2/service/kms v1.16.3/go.mod h1:QuiHPBqlOFCi4LqdSskYYAWpQlx3PKmohy+rE2F+o5g=
 github.com/aws/aws-sdk-go-v2/service/kms v1.16.3/go.mod h1:QuiHPBqlOFCi4LqdSskYYAWpQlx3PKmohy+rE2F+o5g=
-github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.15 h1:ek8ACOAGvDWRm1kFCcj22soNkkLFh4WPBFv7BdWqebs=
-github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.15/go.mod h1:Zf+Tf40dskiGdwVJU2HIgln1vtnQF8QpsguBsbI5Uq8=
+github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.17 h1:b8nlmU7/7j+Tujr7X4YcJ0hb0hqQ/IeXCt8/CjJVO4A=
+github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.17/go.mod h1:kJoiz0fTRMsFZp4BICG6nC++aet5gG9jyjxcGlxxMUs=
 github.com/aws/aws-sdk-go-v2/service/s3 v1.26.3/go.mod h1:g1qvDuRsJY+XghsV6zg00Z4KJ7DtFFCx8fJD2a491Ak=
 github.com/aws/aws-sdk-go-v2/service/s3 v1.26.3/go.mod h1:g1qvDuRsJY+XghsV6zg00Z4KJ7DtFFCx8fJD2a491Ak=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.27.7 h1:BlxqVULzNS7udJIwZBJdL8NNcLbSwgXv/WRJCVUaMm8=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.27.7/go.mod h1:orjy5IRgBQnh9EI/lMW7YGF6eYk6re8HPFbL66a2DSo=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.27.9 h1:imVonvre+AHMcDc3B9bPHHy5ZgjIkkYc/jyDBK8FHFw=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.27.9/go.mod h1:0Gfmg8gjPhVPy/IXkLAmyKZbAue+2s11BWKH+oXggmg=
 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.4/go.mod h1:PJc8s+lxyU8rrre0/4a0pn2wgwiDvOEzoOjcJUBr67o=
 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.4/go.mod h1:PJc8s+lxyU8rrre0/4a0pn2wgwiDvOEzoOjcJUBr67o=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.20 h1:j41VjMJNc5T9AWkLf/FdVtR46st2PZYB/6xoBBY2/8Q=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.20/go.mod h1:F2AUfGEOcxpOTzo/+Bur5PrtsvnhVQQbd4CGfPicOpw=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.22 h1:ggHTCgbIivTM85PFjv/rkJbchrmLSNL+Vcj5hg54TyM=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.22/go.mod h1:zT2j7Ndi+FcBX+zfYLDppqODSgSdKlquB3LPLPVDAts=
 github.com/aws/aws-sdk-go-v2/service/sns v1.17.4/go.mod h1:kElt+uCcXxcqFyc+bQqZPFD9DME/eC6oHBXvFzQ9Bcw=
 github.com/aws/aws-sdk-go-v2/service/sns v1.17.4/go.mod h1:kElt+uCcXxcqFyc+bQqZPFD9DME/eC6oHBXvFzQ9Bcw=
 github.com/aws/aws-sdk-go-v2/service/sqs v1.18.3/go.mod h1:skmQo0UPvsjsuYYSYMVmrPc1HWCbHUJyrCEp+ZaLzqM=
 github.com/aws/aws-sdk-go-v2/service/sqs v1.18.3/go.mod h1:skmQo0UPvsjsuYYSYMVmrPc1HWCbHUJyrCEp+ZaLzqM=
 github.com/aws/aws-sdk-go-v2/service/ssm v1.24.1/go.mod h1:NR/xoKjdbRJ+qx0pMR4mI+N/H1I1ynHwXnO6FowXJc0=
 github.com/aws/aws-sdk-go-v2/service/ssm v1.24.1/go.mod h1:NR/xoKjdbRJ+qx0pMR4mI+N/H1I1ynHwXnO6FowXJc0=
 github.com/aws/aws-sdk-go-v2/service/sso v1.11.3/go.mod h1:7UQ/e69kU7LDPtY40OyoHYgRmgfGM4mgsLYtcObdveU=
 github.com/aws/aws-sdk-go-v2/service/sso v1.11.3/go.mod h1:7UQ/e69kU7LDPtY40OyoHYgRmgfGM4mgsLYtcObdveU=
-github.com/aws/aws-sdk-go-v2/service/sso v1.11.19 h1:WdCwfJmu23XiIDeZwclSyAorQe916M3LeHd53xqBjfA=
-github.com/aws/aws-sdk-go-v2/service/sso v1.11.19/go.mod h1:ytmEi5+qwcSNcV2pVA8PIb1DnKT/0Bu/K4nfJHwoM6c=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.1 h1:p48IfndYbRk3iDsoQAmVXdCKEM5+7Y50JAPikjwk8gI=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.1/go.mod h1:NY+G+8PW0ISyJ7/6t5mgOe6qpJiwZa9Jix05WPscJjg=
+github.com/aws/aws-sdk-go-v2/service/sso v1.11.21 h1:7jUFr+7F4MzIjCZzy7ygRtXFQcQ0kAbT0gUvtUeAdyU=
+github.com/aws/aws-sdk-go-v2/service/sso v1.11.21/go.mod h1:q8nYq51W3gpZempYsAD83fPRlrOTMCwN+Ahg4BKFTXQ=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.3 h1:UTTPNP3/WzZa7hoHP3Szb/Yl0bM3NoBrf5ABy1OArUM=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.3/go.mod h1:+IF75RMJh0+zqTGXGshyEGRsU2ImqWv6UuHGkHl6kEo=
 github.com/aws/aws-sdk-go-v2/service/sts v1.16.3/go.mod h1:bfBj0iVmsUyUg4weDB4NxktD9rDGeKSVWnjTnwbx9b8=
 github.com/aws/aws-sdk-go-v2/service/sts v1.16.3/go.mod h1:bfBj0iVmsUyUg4weDB4NxktD9rDGeKSVWnjTnwbx9b8=
-github.com/aws/aws-sdk-go-v2/service/sts v1.16.15 h1:ApuR2BK9vf5/XXsImHBBsYJ6aUhmUhBHnZMPyhJo1jQ=
-github.com/aws/aws-sdk-go-v2/service/sts v1.16.15/go.mod h1:Y+BUV19q3OmQVqNUlbZ40zVi3NM6Biuxwkx/qdSD/CY=
+github.com/aws/aws-sdk-go-v2/service/sts v1.16.17 h1:LVM2jzEQ8mhb2dhrFl4PJ3sa5+KcKT01dsMk2Ma9/FU=
+github.com/aws/aws-sdk-go-v2/service/sts v1.16.17/go.mod h1:bQujK1n0V1D1Gz5uII1jaB1WDvhj4/T3tElsJnVXCR0=
 github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM=
 github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM=
-github.com/aws/smithy-go v1.13.0 h1:YfyEmSJLo7fAv8FbuDK4R8F9aAmi9DZ88Zb/KJJmUl0=
-github.com/aws/smithy-go v1.13.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
+github.com/aws/smithy-go v1.13.2 h1:TBLKyeJfXTrTXRHmsv4qWt9IQGYyWThLYaJWSahTOGE=
+github.com/aws/smithy-go v1.13.2/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
@@ -239,8 +239,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
 github.com/cockroachdb/cockroach-go/v2 v2.2.15 h1:6TeTC1JLSlHJWJCswWZ7mQyT16kY5mQSs53C2coQISI=
 github.com/cockroachdb/cockroach-go/v2 v2.2.15 h1:6TeTC1JLSlHJWJCswWZ7mQyT16kY5mQSs53C2coQISI=
 github.com/cockroachdb/cockroach-go/v2 v2.2.15/go.mod h1:xZ2VHjUEb/cySv0scXBx7YsBnHtLHkR1+w/w73b5i3M=
 github.com/cockroachdb/cockroach-go/v2 v2.2.15/go.mod h1:xZ2VHjUEb/cySv0scXBx7YsBnHtLHkR1+w/w73b5i3M=
-github.com/coreos/go-oidc/v3 v3.2.0 h1:2eR2MGR7thBXSQ2YbODlF0fcmgtliLCfr9iX6RW11fc=
-github.com/coreos/go-oidc/v3 v3.2.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo=
+github.com/coreos/go-oidc/v3 v3.3.0 h1:Y1LV3mP+QT3MEycATZpAiwfyN+uxZLqVbAHJUuOJEe4=
+github.com/coreos/go-oidc/v3 v3.3.0/go.mod h1:eHUXhZtXPQLgEaDrOVTgwbgmz1xGOkJNye6h3zkD2Pw=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
@@ -268,8 +268,6 @@ github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHP
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
 github.com/drakkan/net v0.0.0-20220828084259-1562d1fb0fc5 h1:+sVMXrU1DiQLNDgz1KvybqHEzRf8KuX5xQW8fpii6rI=
 github.com/drakkan/net v0.0.0-20220828084259-1562d1fb0fc5 h1:+sVMXrU1DiQLNDgz1KvybqHEzRf8KuX5xQW8fpii6rI=
 github.com/drakkan/net v0.0.0-20220828084259-1562d1fb0fc5/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 github.com/drakkan/net v0.0.0-20220828084259-1562d1fb0fc5/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
-github.com/drakkan/sftp v0.0.0-20220716075551-51a5aa4e044d h1:kNk/KRhszPJASp7WvjagNW254aKK643Lu8/fr4/ukiM=
-github.com/drakkan/sftp v0.0.0-20220716075551-51a5aa4e044d/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
 github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001 h1:/ZshrfQzayqRSBDodmp3rhNCHJCff+utvgBuWRbiqu4=
 github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001 h1:/ZshrfQzayqRSBDodmp3rhNCHJCff+utvgBuWRbiqu4=
 github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84=
 github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -662,6 +660,9 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
+github.com/pkg/sftp v1.13.6-0.20220831160757-628507938ec6 h1:hxLT9qX4jw+GjGuPA6XHtooT1+nf/hr5anQtACaXZmY=
+github.com/pkg/sftp v1.13.6-0.20220831160757-628507938ec6/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
 github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
@@ -715,8 +716,8 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/sftpgo/sdk v0.1.2-0.20220828084006-f9e2fffac657 h1:UXTpae6d+G/VI3sVITl+58SK0F3ZULn9dlEPMXcyNKY=
 github.com/sftpgo/sdk v0.1.2-0.20220828084006-f9e2fffac657 h1:UXTpae6d+G/VI3sVITl+58SK0F3ZULn9dlEPMXcyNKY=
 github.com/sftpgo/sdk v0.1.2-0.20220828084006-f9e2fffac657/go.mod h1:PTp1TfXa+95wHw9yuZu7BA3vmzLqbRkz3gBmMNnwFQg=
 github.com/sftpgo/sdk v0.1.2-0.20220828084006-f9e2fffac657/go.mod h1:PTp1TfXa+95wHw9yuZu7BA3vmzLqbRkz3gBmMNnwFQg=
-github.com/shirou/gopsutil/v3 v3.22.7 h1:flKnuCMfUUrO+oAvwAd6GKZgnPzr098VA/UJ14nhJd4=
-github.com/shirou/gopsutil/v3 v3.22.7/go.mod h1:s648gW4IywYzUfE/KjXxUsqrqx/T2xO5VqOXxONeRfI=
+github.com/shirou/gopsutil/v3 v3.22.8 h1:a4s3hXogo5mE2PfdfJIonDbstO/P+9JszdfhAHSzD9Y=
+github.com/shirou/gopsutil/v3 v3.22.8/go.mod h1:s648gW4IywYzUfE/KjXxUsqrqx/T2xO5VqOXxONeRfI=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
@@ -938,6 +939,7 @@ golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1222,8 +1224,8 @@ google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP
 google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
-google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf h1:Q5xNKbTSFwkuaaGaR7CMcXEM5sy19KYdUU8iF8/iRC0=
-google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
+google.golang.org/genproto v0.0.0-20220902135211-223410557253 h1:vXJMM8Shg7TGaYxZsQ++A/FOSlbDmDtWhS/o+3w/hj4=
+google.golang.org/genproto v0.0.0-20220902135211-223410557253/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 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.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -1286,7 +1288,6 @@ gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
 gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
 gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
 gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
 gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
-gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
 gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
 gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

+ 159 - 29
internal/common/eventmanager.go

@@ -20,7 +20,10 @@ import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
+	"mime"
+	"mime/multipart"
 	"net/http"
 	"net/http"
+	"net/textproto"
 	"net/url"
 	"net/url"
 	"os"
 	"os"
 	"os/exec"
 	"os/exec"
@@ -49,7 +52,8 @@ const (
 
 
 var (
 var (
 	// eventManager handle the supported event rules actions
 	// eventManager handle the supported event rules actions
-	eventManager eventRulesContainer
+	eventManager          eventRulesContainer
+	multipartQuoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
 )
 )
 
 
 func init() {
 func init() {
@@ -455,7 +459,12 @@ func (p *EventParams) getStatusString() string {
 // getUsers returns users with group settings not applied
 // getUsers returns users with group settings not applied
 func (p *EventParams) getUsers() ([]dataprovider.User, error) {
 func (p *EventParams) getUsers() ([]dataprovider.User, error) {
 	if p.sender == "" {
 	if p.sender == "" {
-		return dataprovider.DumpUsers()
+		users, err := dataprovider.DumpUsers()
+		if err != nil {
+			eventManagerLog(logger.LevelError, "unable to get users: %+v", err)
+			return users, errors.New("unable to get users")
+		}
+		return users, nil
 	}
 	}
 	user, err := p.getUserFromSender()
 	user, err := p.getUserFromSender()
 	if err != nil {
 	if err != nil {
@@ -467,7 +476,8 @@ func (p *EventParams) getUsers() ([]dataprovider.User, error) {
 func (p *EventParams) getUserFromSender() (dataprovider.User, error) {
 func (p *EventParams) getUserFromSender() (dataprovider.User, error) {
 	user, err := dataprovider.UserExists(p.sender)
 	user, err := dataprovider.UserExists(p.sender)
 	if err != nil {
 	if err != nil {
-		return user, fmt.Errorf("error getting user %q: %w", p.sender, err)
+		eventManagerLog(logger.LevelError, "unable to get user %q: %+v", p.sender, err)
+		return user, fmt.Errorf("error getting user %q", p.sender)
 	}
 	}
 	return user, nil
 	return user, nil
 }
 }
@@ -515,26 +525,45 @@ func (p *EventParams) getStringReplacements(addObjectData bool) []string {
 	return replacements
 	return replacements
 }
 }
 
 
-func getFileContent(conn *BaseConnection, virtualPath string, expectedSize int) ([]byte, error) {
+func getFileReader(conn *BaseConnection, virtualPath string) (io.ReadCloser, func(), error) {
 	fs, fsPath, err := conn.GetFsAndResolvedPath(virtualPath)
 	fs, fsPath, err := conn.GetFsAndResolvedPath(virtualPath)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	f, r, cancelFn, err := fs.Open(fsPath, 0)
 	f, r, cancelFn, err := fs.Open(fsPath, 0)
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	}
 	if cancelFn == nil {
 	if cancelFn == nil {
 		cancelFn = func() {}
 		cancelFn = func() {}
 	}
 	}
-	defer cancelFn()
 
 
-	var reader io.ReadCloser
 	if f != nil {
 	if f != nil {
-		reader = f
-	} else {
-		reader = r
+		return f, cancelFn, nil
+	}
+	return r, cancelFn, nil
+}
+
+func writeFileContent(conn *BaseConnection, virtualPath string, w io.Writer) error {
+	reader, cancelFn, err := getFileReader(conn, virtualPath)
+	if err != nil {
+		return err
+	}
+
+	defer cancelFn()
+	defer reader.Close()
+
+	_, err = io.Copy(w, reader)
+	return err
+}
+
+func getFileContent(conn *BaseConnection, virtualPath string, expectedSize int) ([]byte, error) {
+	reader, cancelFn, err := getFileReader(conn, virtualPath)
+	if err != nil {
+		return nil, err
 	}
 	}
+
+	defer cancelFn()
 	defer reader.Close()
 	defer reader.Close()
 
 
 	data := make([]byte, expectedSize)
 	data := make([]byte, expectedSize)
@@ -632,19 +661,103 @@ func getHTTPRuleActionEndpoint(c dataprovider.EventActionHTTPConfig, replacer *s
 	return c.Endpoint, nil
 	return c.Endpoint, nil
 }
 }
 
 
-func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params *EventParams) error {
-	if !c.Password.IsEmpty() {
-		if err := c.Password.TryDecrypt(); err != nil {
-			return fmt.Errorf("unable to decrypt password: %w", err)
+func writeHTTPPart(m *multipart.Writer, part dataprovider.HTTPPart, h textproto.MIMEHeader,
+	conn *BaseConnection, replacer *strings.Replacer,
+) error {
+	partWriter, err := m.CreatePart(h)
+	if err != nil {
+		eventManagerLog(logger.LevelError, "unable to create part %q, err: %v", part.Name, err)
+		return err
+	}
+	if part.Body != "" {
+		_, err = partWriter.Write([]byte(replaceWithReplacer(part.Body, replacer)))
+		if err != nil {
+			eventManagerLog(logger.LevelError, "unable to write part %q, err: %v", part.Name, err)
+			return err
 		}
 		}
+		return nil
 	}
 	}
-	addObjectData := false
-	if params.Object != nil {
-		if !addObjectData {
-			if strings.Contains(c.Body, "{{ObjectData}}") {
-				addObjectData = true
+	err = writeFileContent(conn, util.CleanPath(replacer.Replace(part.Filepath)), partWriter)
+	if err != nil {
+		eventManagerLog(logger.LevelError, "unable to write file part %q, err: %v", part.Name, err)
+		return err
+	}
+	return nil
+}
+
+func getHTTPRuleActionBody(c dataprovider.EventActionHTTPConfig, replacer *strings.Replacer,
+	cancel context.CancelFunc, user dataprovider.User,
+) (io.ReadCloser, string, error) {
+	var body io.ReadCloser
+	if c.Method == http.MethodGet {
+		return body, "", nil
+	}
+	if c.Body != "" {
+		return io.NopCloser(bytes.NewBufferString(replaceWithReplacer(c.Body, replacer))), "", nil
+	}
+	if len(c.Parts) > 0 {
+		r, w := io.Pipe()
+		m := multipart.NewWriter(w)
+
+		var conn *BaseConnection
+		if user.Username != "" {
+			var err error
+			user, err = getUserForEventAction(user)
+			if err != nil {
+				return body, "", err
+			}
+			connectionID := fmt.Sprintf("%s_%s", protocolEventAction, xid.New().String())
+			err = user.CheckFsRoot(connectionID)
+			if err != nil {
+				user.CloseFs() //nolint:errcheck
+				return body, "", fmt.Errorf("error getting multipart file/s, unable to check root fs for user %q: %w",
+					user.Username, err)
 			}
 			}
+			conn = NewBaseConnection(connectionID, protocolEventAction, "", "", user)
 		}
 		}
+
+		go func() {
+			defer w.Close()
+			defer user.CloseFs() //nolint:errcheck
+
+			for _, part := range c.Parts {
+				h := make(textproto.MIMEHeader)
+				if part.Body != "" {
+					h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"`, multipartQuoteEscaper.Replace(part.Name)))
+				} else {
+					filePath := util.CleanPath(replacer.Replace(part.Filepath))
+					h.Set("Content-Disposition",
+						fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
+							multipartQuoteEscaper.Replace(part.Name), multipartQuoteEscaper.Replace(path.Base(filePath))))
+					contentType := mime.TypeByExtension(path.Ext(filePath))
+					if contentType == "" {
+						contentType = "application/octet-stream"
+					}
+					h.Set("Content-Type", contentType)
+				}
+				for _, keyVal := range part.Headers {
+					h.Set(keyVal.Key, replaceWithReplacer(keyVal.Value, replacer))
+				}
+				if err := writeHTTPPart(m, part, h, conn, replacer); err != nil {
+					cancel()
+					return
+				}
+			}
+			m.Close()
+		}()
+
+		return r, m.FormDataContentType(), nil
+	}
+	return body, "", nil
+}
+
+func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params *EventParams) error {
+	if err := c.TryDecryptPassword(); err != nil {
+		return err
+	}
+	addObjectData := false
+	if params.Object != nil {
+		addObjectData = c.HasObjectData()
 	}
 	}
 
 
 	replacements := params.getStringReplacements(addObjectData)
 	replacements := params.getStringReplacements(addObjectData)
@@ -654,16 +767,32 @@ func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params *EventPa
 		return err
 		return err
 	}
 	}
 
 
-	var body io.Reader
-	if c.Body != "" && c.Method != http.MethodGet {
-		body = bytes.NewBufferString(replaceWithReplacer(c.Body, replacer))
+	ctx, cancel := c.GetContext()
+	defer cancel()
+
+	var user dataprovider.User
+	if c.HasMultipartFile() {
+		user, err = params.getUserFromSender()
+		if err != nil {
+			return err
+		}
 	}
 	}
-	req, err := http.NewRequest(c.Method, endpoint, body)
+	body, contentType, err := getHTTPRuleActionBody(c, replacer, cancel, user)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
+	if body != nil {
+		defer body.Close()
+	}
+	req, err := http.NewRequestWithContext(ctx, c.Method, endpoint, body)
+	if err != nil {
+		return err
+	}
+	if contentType != "" {
+		req.Header.Set("Content-Type", contentType)
+	}
 	if c.Username != "" {
 	if c.Username != "" {
-		req.SetBasicAuth(replaceWithReplacer(c.Username, replacer), c.Password.GetAdditionalData())
+		req.SetBasicAuth(replaceWithReplacer(c.Username, replacer), c.Password.GetPayload())
 	}
 	}
 	for _, keyVal := range c.Headers {
 	for _, keyVal := range c.Headers {
 		req.Header.Set(keyVal.Key, replaceWithReplacer(keyVal.Value, replacer))
 		req.Header.Set(keyVal.Key, replaceWithReplacer(keyVal.Value, replacer))
@@ -676,11 +805,11 @@ func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params *EventPa
 	if err != nil {
 	if err != nil {
 		eventManagerLog(logger.LevelDebug, "unable to send http notification, endpoint: %s, elapsed: %s, err: %v",
 		eventManagerLog(logger.LevelDebug, "unable to send http notification, endpoint: %s, elapsed: %s, err: %v",
 			endpoint, time.Since(startTime), err)
 			endpoint, time.Since(startTime), err)
-		return err
+		return fmt.Errorf("error sending HTTP request: %w", err)
 	}
 	}
 	defer resp.Body.Close()
 	defer resp.Body.Close()
 
 
-	eventManagerLog(logger.LevelDebug, "http notification sent, endopoint: %s, elapsed: %s, status code: %d",
+	eventManagerLog(logger.LevelDebug, "http notification sent, endpoint: %s, elapsed: %s, status code: %d",
 		endpoint, time.Since(startTime), resp.StatusCode)
 		endpoint, time.Since(startTime), resp.StatusCode)
 	if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusNoContent {
 	if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusNoContent {
 		return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
 		return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
@@ -761,14 +890,15 @@ func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params *Event
 func getUserForEventAction(user dataprovider.User) (dataprovider.User, error) {
 func getUserForEventAction(user dataprovider.User) (dataprovider.User, error) {
 	err := user.LoadAndApplyGroupSettings()
 	err := user.LoadAndApplyGroupSettings()
 	if err != nil {
 	if err != nil {
-		return dataprovider.User{}, err
+		eventManagerLog(logger.LevelError, "unable to get group for user %q: %+v", user.Username, err)
+		return dataprovider.User{}, fmt.Errorf("unable to get groups for user %q", user.Username)
 	}
 	}
 	user.Filters.DisableFsChecks = false
 	user.Filters.DisableFsChecks = false
 	user.Filters.FilePatterns = nil
 	user.Filters.FilePatterns = nil
 	for k := range user.Permissions {
 	for k := range user.Permissions {
 		user.Permissions[k] = []string{dataprovider.PermAny}
 		user.Permissions[k] = []string{dataprovider.PermAny}
 	}
 	}
-	return user, err
+	return user, nil
 }
 }
 
 
 func executeDeleteFileFsAction(conn *BaseConnection, item string, info os.FileInfo) error {
 func executeDeleteFileFsAction(conn *BaseConnection, item string, info os.FileInfo) error {

+ 90 - 2
internal/common/eventmanager_test.go

@@ -17,6 +17,8 @@ package common
 import (
 import (
 	"crypto/rand"
 	"crypto/rand"
 	"fmt"
 	"fmt"
+	"io"
+	"mime/multipart"
 	"net/http"
 	"net/http"
 	"os"
 	"os"
 	"path"
 	"path"
@@ -349,6 +351,26 @@ func TestEventManagerErrors(t *testing.T) {
 		}}, []string{"/a", "/b"}, nil)
 		}}, []string{"/a", "/b"}, nil)
 	assert.Error(t, err)
 	assert.Error(t, err)
 
 
+	_, _, err = getHTTPRuleActionBody(dataprovider.EventActionHTTPConfig{
+		Method: http.MethodPost,
+		Parts: []dataprovider.HTTPPart{
+			{
+				Name: "p1",
+			},
+		},
+	}, nil, nil, dataprovider.User{
+		BaseUser: sdk.BaseUser{
+			Username: "u",
+		},
+		Groups: []sdk.GroupMapping{
+			{
+				Name: groupName,
+				Type: sdk.GroupTypePrimary,
+			},
+		},
+	})
+	assert.Error(t, err)
+
 	dataRetentionAction := dataprovider.BaseEventAction{
 	dataRetentionAction := dataprovider.BaseEventAction{
 		Type: dataprovider.ActionTypeDataRetentionCheck,
 		Type: dataprovider.ActionTypeDataRetentionCheck,
 		Options: dataprovider.BaseEventActionOptions{
 		Options: dataprovider.BaseEventActionOptions{
@@ -469,6 +491,9 @@ func TestEventRuleActions(t *testing.T) {
 	}
 	}
 	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
 	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
 	assert.NoError(t, err)
 	assert.NoError(t, err)
+	action.Options.HTTPConfig.Method = http.MethodGet
+	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
+	assert.NoError(t, err)
 	action.Options.HTTPConfig.Endpoint = fmt.Sprintf("http://%v/404", httpAddr)
 	action.Options.HTTPConfig.Endpoint = fmt.Sprintf("http://%v/404", httpAddr)
 	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
 	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
 	if assert.Error(t, err) {
 	if assert.Error(t, err) {
@@ -484,8 +509,22 @@ func TestEventRuleActions(t *testing.T) {
 	action.Options.HTTPConfig.Password = kms.NewSecret(sdkkms.SecretStatusSecretBox, "payload", "key", "data")
 	action.Options.HTTPConfig.Password = kms.NewSecret(sdkkms.SecretStatusSecretBox, "payload", "key", "data")
 	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
 	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
 	if assert.Error(t, err) {
 	if assert.Error(t, err) {
-		assert.Contains(t, err.Error(), "unable to decrypt password")
+		assert.Contains(t, err.Error(), "unable to decrypt HTTP password")
+	}
+	action.Options.HTTPConfig.Password = kms.NewEmptySecret()
+	action.Options.HTTPConfig.Body = ""
+	action.Options.HTTPConfig.Parts = []dataprovider.HTTPPart{
+		{
+			Name:     "p1",
+			Filepath: "path",
+		},
+	}
+	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "error getting user")
 	}
 	}
+	action.Options.HTTPConfig.Parts = nil
+	action.Options.HTTPConfig.Body = "{{ObjectData}}"
 	// test disk and transfer quota reset
 	// test disk and transfer quota reset
 	username1 := "user1"
 	username1 := "user1"
 	username2 := "user2"
 	username2 := "user2"
@@ -849,6 +888,12 @@ func TestEventRuleActions(t *testing.T) {
 		assert.Contains(t, err.Error(), "no folder quota reset executed")
 		assert.Contains(t, err.Error(), "no folder quota reset executed")
 	}
 	}
 
 
+	body, _, err := getHTTPRuleActionBody(dataprovider.EventActionHTTPConfig{
+		Method: http.MethodPost,
+	}, nil, nil, dataprovider.User{})
+	assert.NoError(t, err)
+	assert.Nil(t, body)
+
 	err = os.RemoveAll(folder1.MappedPath)
 	err = os.RemoveAll(folder1.MappedPath)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	err = dataprovider.DeleteFolder(foldername1, "", "")
 	err = dataprovider.DeleteFolder(foldername1, "", "")
@@ -979,7 +1024,19 @@ func TestFilesystemActionErrors(t *testing.T) {
 	assert.Error(t, err)
 	assert.Error(t, err)
 	_, err = getFileContent(NewBaseConnection("", protocolEventAction, "", "", user), "/f.txt", 1234)
 	_, err = getFileContent(NewBaseConnection("", protocolEventAction, "", "", user), "/f.txt", 1234)
 	assert.Error(t, err)
 	assert.Error(t, err)
-
+	err = executeHTTPRuleAction(dataprovider.EventActionHTTPConfig{
+		Endpoint: "http://127.0.0.1:9999/",
+		Method:   http.MethodPost,
+		Parts: []dataprovider.HTTPPart{
+			{
+				Name:     "p1",
+				Filepath: "/filepath",
+			},
+		},
+	}, &EventParams{
+		sender: username,
+	})
+	assert.Error(t, err)
 	user.FsConfig.Provider = sdk.LocalFilesystemProvider
 	user.FsConfig.Provider = sdk.LocalFilesystemProvider
 	user.Permissions["/"] = []string{dataprovider.PermUpload}
 	user.Permissions["/"] = []string{dataprovider.PermUpload}
 	err = dataprovider.DeleteUser(username, "", "")
 	err = dataprovider.DeleteUser(username, "", "")
@@ -1276,3 +1333,34 @@ func TestEventParamsStatusFromError(t *testing.T) {
 	params.AddError(os.ErrNotExist)
 	params.AddError(os.ErrNotExist)
 	assert.Equal(t, 2, params.Status)
 	assert.Equal(t, 2, params.Status)
 }
 }
+
+type testWriter struct {
+	errTest  error
+	sentinel string
+}
+
+func (w *testWriter) Write(p []byte) (int, error) {
+	if w.errTest != nil {
+		return 0, w.errTest
+	}
+	if w.sentinel == string(p) {
+		return 0, io.ErrUnexpectedEOF
+	}
+	return len(p), nil
+}
+
+func TestWriteHTTPPartsError(t *testing.T) {
+	m := multipart.NewWriter(&testWriter{
+		errTest: io.ErrShortWrite,
+	})
+
+	err := writeHTTPPart(m, dataprovider.HTTPPart{}, nil, nil, nil)
+	assert.ErrorIs(t, err, io.ErrShortWrite)
+
+	body := "test body"
+	m = multipart.NewWriter(&testWriter{sentinel: body})
+	err = writeHTTPPart(m, dataprovider.HTTPPart{
+		Body: body,
+	}, nil, nil, nil)
+	assert.ErrorIs(t, err, io.ErrUnexpectedEOF)
+}

+ 98 - 0
internal/common/protocol_test.go

@@ -169,6 +169,16 @@ func TestMain(m *testing.M) {
 			w.WriteHeader(http.StatusNotFound)
 			w.WriteHeader(http.StatusNotFound)
 			fmt.Fprintf(w, "Not found\n")
 			fmt.Fprintf(w, "Not found\n")
 		})
 		})
+		http.HandleFunc("/multipart", func(w http.ResponseWriter, r *http.Request) {
+			err := r.ParseMultipartForm(1048576)
+			if err != nil {
+				w.WriteHeader(http.StatusInternalServerError)
+				fmt.Fprintf(w, "KO\n")
+				return
+			}
+			defer r.MultipartForm.RemoveAll() //nolint:errcheck
+			fmt.Fprintf(w, "OK\n")
+		})
 		if err := http.ListenAndServe(httpAddr, nil); err != nil {
 		if err := http.ListenAndServe(httpAddr, nil); err != nil {
 			logger.ErrorToConsole("could not start HTTP notification server: %v", err)
 			logger.ErrorToConsole("could not start HTTP notification server: %v", err)
 			os.Exit(1)
 			os.Exit(1)
@@ -3815,6 +3825,94 @@ func TestEventRuleFsActions(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestEventActionHTTPMultipart(t *testing.T) {
+	a1 := dataprovider.BaseEventAction{
+		Name: "action1",
+		Type: dataprovider.ActionTypeHTTP,
+		Options: dataprovider.BaseEventActionOptions{
+			HTTPConfig: dataprovider.EventActionHTTPConfig{
+				Endpoint: fmt.Sprintf("http://%s/multipart", httpAddr),
+				Method:   http.MethodPut,
+				Parts: []dataprovider.HTTPPart{
+					{
+						Name: "part1",
+						Headers: []dataprovider.KeyValue{
+							{
+								Key:   "Content-Type",
+								Value: "application/json",
+							},
+						},
+						Body: `{"FilePath": "{{VirtualPath}}"}`,
+					},
+					{
+						Name:     "file",
+						Filepath: "/{{VirtualPath}}",
+					},
+				},
+			},
+		},
+	}
+	action1, resp, err := httpdtest.AddEventAction(a1, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+	r1 := dataprovider.EventRule{
+		Name:    "test http multipart",
+		Trigger: dataprovider.EventTriggerFsEvent,
+		Conditions: dataprovider.EventConditions{
+			FsEvents: []string{"upload"},
+		},
+		Actions: []dataprovider.EventAction{
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: action1.Name,
+				},
+				Options: dataprovider.EventActionOptions{
+					ExecuteSync: true,
+				},
+				Order: 1,
+			},
+		},
+	}
+	rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated)
+	assert.NoError(t, err)
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+	conn, client, err := getSftpClient(user)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+
+		f, err := client.Create(testFileName)
+		assert.NoError(t, err)
+		_, err = f.Write(testFileContent)
+		assert.NoError(t, err)
+		err = f.Close()
+		assert.NoError(t, err)
+		// now add an missing file to the http multipart action
+		action1.Options.HTTPConfig.Parts = append(action1.Options.HTTPConfig.Parts, dataprovider.HTTPPart{
+			Name:     "file1",
+			Filepath: "/missing",
+		})
+		_, resp, err = httpdtest.UpdateEventAction(action1, http.StatusOK)
+		assert.NoError(t, err, string(resp))
+
+		f, err = client.Create("testfile.txt")
+		assert.NoError(t, err)
+		_, err = f.Write(testFileContent)
+		assert.NoError(t, err)
+		err = f.Close()
+		assert.Error(t, err)
+	}
+
+	_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestEventActionEmailAttachments(t *testing.T) {
 func TestEventActionEmailAttachments(t *testing.T) {
 	smtpCfg := smtp.Config{
 	smtpCfg := smtp.Config{
 		Host:          "127.0.0.1",
 		Host:          "127.0.0.1",

+ 126 - 8
internal/dataprovider/eventrule.go

@@ -15,6 +15,7 @@
 package dataprovider
 package dataprovider
 
 
 import (
 import (
+	"context"
 	"crypto/tls"
 	"crypto/tls"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
@@ -200,6 +201,39 @@ type KeyValue struct {
 	Value string `json:"value"`
 	Value string `json:"value"`
 }
 }
 
 
+func (k *KeyValue) isNotValid() bool {
+	return k.Key == "" || k.Value == ""
+}
+
+// HTTPPart defines a part for HTTP multipart requests
+type HTTPPart struct {
+	Name     string     `json:"name,omitempty"`
+	Filepath string     `json:"filepath,omitempty"`
+	Headers  []KeyValue `json:"headers,omitempty"`
+	Body     string     `json:"body,omitempty"`
+	Order    int        `json:"-"`
+}
+
+func (p *HTTPPart) validate() error {
+	if p.Name == "" {
+		return util.NewValidationError("HTTP part name is required")
+	}
+	for _, kv := range p.Headers {
+		if kv.isNotValid() {
+			return util.NewValidationError("invalid HTTP part headers")
+		}
+	}
+	if p.Filepath == "" {
+		if p.Body == "" {
+			return util.NewValidationError("HTTP part body is required if no file path is provided")
+		}
+	} else {
+		p.Body = ""
+		p.Filepath = util.CleanPath(p.Filepath)
+	}
+	return nil
+}
+
 // EventActionHTTPConfig defines the configuration for an HTTP event target
 // EventActionHTTPConfig defines the configuration for an HTTP event target
 type EventActionHTTPConfig struct {
 type EventActionHTTPConfig struct {
 	Endpoint        string      `json:"endpoint,omitempty"`
 	Endpoint        string      `json:"endpoint,omitempty"`
@@ -210,7 +244,34 @@ type EventActionHTTPConfig struct {
 	SkipTLSVerify   bool        `json:"skip_tls_verify,omitempty"`
 	SkipTLSVerify   bool        `json:"skip_tls_verify,omitempty"`
 	Method          string      `json:"method,omitempty"`
 	Method          string      `json:"method,omitempty"`
 	QueryParameters []KeyValue  `json:"query_parameters,omitempty"`
 	QueryParameters []KeyValue  `json:"query_parameters,omitempty"`
-	Body            string      `json:"post_body,omitempty"`
+	Body            string      `json:"body,omitempty"`
+	Parts           []HTTPPart  `json:"parts,omitempty"`
+}
+
+func (c *EventActionHTTPConfig) isTimeoutNotValid() bool {
+	if c.HasMultipartFile() {
+		return false
+	}
+	return c.Timeout < 1 || c.Timeout > 120
+}
+
+func (c *EventActionHTTPConfig) validateMultiparts() error {
+	for idx := range c.Parts {
+		if err := c.Parts[idx].validate(); err != nil {
+			return err
+		}
+	}
+	if len(c.Parts) > 0 {
+		if c.Body != "" {
+			return util.NewValidationError("multipart requests require no body. The request body is build from the specified parts")
+		}
+		for _, k := range c.Headers {
+			if strings.ToLower(k.Key) == "content-type" {
+				return util.NewValidationError("content type is automatically set for multipart requests")
+			}
+		}
+	}
+	return nil
 }
 }
 
 
 func (c *EventActionHTTPConfig) validate(additionalData string) error {
 func (c *EventActionHTTPConfig) validate(additionalData string) error {
@@ -220,14 +281,17 @@ func (c *EventActionHTTPConfig) validate(additionalData string) error {
 	if !util.IsStringPrefixInSlice(c.Endpoint, []string{"http://", "https://"}) {
 	if !util.IsStringPrefixInSlice(c.Endpoint, []string{"http://", "https://"}) {
 		return util.NewValidationError("invalid HTTP endpoint schema: http and https are supported")
 		return util.NewValidationError("invalid HTTP endpoint schema: http and https are supported")
 	}
 	}
-	if c.Timeout < 1 || c.Timeout > 120 {
+	if c.isTimeoutNotValid() {
 		return util.NewValidationError(fmt.Sprintf("invalid HTTP timeout %d", c.Timeout))
 		return util.NewValidationError(fmt.Sprintf("invalid HTTP timeout %d", c.Timeout))
 	}
 	}
 	for _, kv := range c.Headers {
 	for _, kv := range c.Headers {
-		if kv.Key == "" || kv.Value == "" {
+		if kv.isNotValid() {
 			return util.NewValidationError("invalid HTTP headers")
 			return util.NewValidationError("invalid HTTP headers")
 		}
 		}
 	}
 	}
+	if err := c.validateMultiparts(); err != nil {
+		return err
+	}
 	if c.Password.IsRedacted() {
 	if c.Password.IsRedacted() {
 		return util.NewValidationError("cannot save HTTP configuration with a redacted secret")
 		return util.NewValidationError("cannot save HTTP configuration with a redacted secret")
 	}
 	}
@@ -242,18 +306,57 @@ func (c *EventActionHTTPConfig) validate(additionalData string) error {
 		return util.NewValidationError(fmt.Sprintf("unsupported HTTP method: %s", c.Method))
 		return util.NewValidationError(fmt.Sprintf("unsupported HTTP method: %s", c.Method))
 	}
 	}
 	for _, kv := range c.QueryParameters {
 	for _, kv := range c.QueryParameters {
-		if kv.Key == "" || kv.Value == "" {
+		if kv.isNotValid() {
 			return util.NewValidationError("invalid HTTP query parameters")
 			return util.NewValidationError("invalid HTTP query parameters")
 		}
 		}
 	}
 	}
 	return nil
 	return nil
 }
 }
 
 
+// GetContext returns the context and the cancel func to use for the HTTP request
+func (c *EventActionHTTPConfig) GetContext() (context.Context, context.CancelFunc) {
+	if c.HasMultipartFile() {
+		return context.WithCancel(context.Background())
+	}
+	return context.WithTimeout(context.Background(), time.Duration(c.Timeout)*time.Second)
+}
+
+// HasObjectData returns true if the {{ObjectData}} placeholder is defined
+func (c *EventActionHTTPConfig) HasObjectData() bool {
+	if strings.Contains(c.Body, "{{ObjectData}}") {
+		return true
+	}
+	for _, part := range c.Parts {
+		if strings.Contains(part.Body, "{{ObjectData}}") {
+			return true
+		}
+	}
+	return false
+}
+
+// HasMultipartFile returns true if a file must be uploaded via a multipart request
+func (c *EventActionHTTPConfig) HasMultipartFile() bool {
+	for _, part := range c.Parts {
+		if part.Filepath != "" {
+			return true
+		}
+	}
+	return false
+}
+
+// TryDecryptPassword decrypts the password if encryptet
+func (c *EventActionHTTPConfig) TryDecryptPassword() error {
+	if c.Password != nil && !c.Password.IsEmpty() {
+		if err := c.Password.TryDecrypt(); err != nil {
+			return fmt.Errorf("unable to decrypt HTTP password: %w", err)
+		}
+	}
+	return nil
+}
+
 // GetHTTPClient returns an HTTP client based on the config
 // GetHTTPClient returns an HTTP client based on the config
 func (c *EventActionHTTPConfig) GetHTTPClient() *http.Client {
 func (c *EventActionHTTPConfig) GetHTTPClient() *http.Client {
-	client := &http.Client{
-		Timeout: time.Duration(c.Timeout) * time.Second,
-	}
+	client := &http.Client{}
 	if c.SkipTLSVerify {
 	if c.SkipTLSVerify {
 		transport := http.DefaultTransport.(*http.Transport).Clone()
 		transport := http.DefaultTransport.(*http.Transport).Clone()
 		if transport.TLSClientConfig != nil {
 		if transport.TLSClientConfig != nil {
@@ -288,7 +391,7 @@ func (c *EventActionCommandConfig) validate() error {
 		return util.NewValidationError(fmt.Sprintf("invalid command action timeout %d", c.Timeout))
 		return util.NewValidationError(fmt.Sprintf("invalid command action timeout %d", c.Timeout))
 	}
 	}
 	for _, kv := range c.EnvVars {
 	for _, kv := range c.EnvVars {
-		if kv.Key == "" || kv.Value == "" {
+		if kv.isNotValid() {
 			return util.NewValidationError("invalid command env vars")
 			return util.NewValidationError("invalid command env vars")
 		}
 		}
 	}
 	}
@@ -589,6 +692,15 @@ func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions {
 			IgnoreUserPermissions: folder.IgnoreUserPermissions,
 			IgnoreUserPermissions: folder.IgnoreUserPermissions,
 		})
 		})
 	}
 	}
+	httpParts := make([]HTTPPart, 0, len(o.HTTPConfig.Parts))
+	for _, part := range o.HTTPConfig.Parts {
+		httpParts = append(httpParts, HTTPPart{
+			Name:     part.Name,
+			Filepath: part.Filepath,
+			Headers:  cloneKeyValues(part.Headers),
+			Body:     part.Body,
+		})
+	}
 
 
 	return BaseEventActionOptions{
 	return BaseEventActionOptions{
 		HTTPConfig: EventActionHTTPConfig{
 		HTTPConfig: EventActionHTTPConfig{
@@ -601,6 +713,7 @@ func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions {
 			Method:          o.HTTPConfig.Method,
 			Method:          o.HTTPConfig.Method,
 			QueryParameters: cloneKeyValues(o.HTTPConfig.QueryParameters),
 			QueryParameters: cloneKeyValues(o.HTTPConfig.QueryParameters),
 			Body:            o.HTTPConfig.Body,
 			Body:            o.HTTPConfig.Body,
+			Parts:           httpParts,
 		},
 		},
 		CmdConfig: EventActionCommandConfig{
 		CmdConfig: EventActionCommandConfig{
 			Cmd:     o.CmdConfig.Cmd,
 			Cmd:     o.CmdConfig.Cmd,
@@ -1171,6 +1284,11 @@ func (r *EventRule) CheckActionsConsistency(providerObjectType string) error {
 				return errors.New("cannot send an email with attachments for a rule with no user associated")
 				return errors.New("cannot send an email with attachments for a rule with no user associated")
 			}
 			}
 		}
 		}
+		if action.Type == ActionTypeHTTP && action.BaseEventAction.Options.HTTPConfig.HasMultipartFile() {
+			if !r.hasUserAssociated(providerObjectType) {
+				return errors.New("cannot upload file/s for a rule with no user associated")
+			}
+		}
 	}
 	}
 	return nil
 	return nil
 }
 }

+ 68 - 2
internal/httpd/httpd_test.go

@@ -1490,7 +1490,7 @@ func TestEventActionValidation(t *testing.T) {
 			Value: "application/json",
 			Value: "application/json",
 		},
 		},
 	}
 	}
-	action.Options.HTTPConfig.Password = kms.NewSecret(sdkkms.SecretStatusRedacted, "paylod", "", "")
+	action.Options.HTTPConfig.Password = kms.NewSecret(sdkkms.SecretStatusRedacted, "payload", "", "")
 	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
 	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Contains(t, string(resp), "cannot save HTTP configuration with a redacted secret")
 	assert.Contains(t, string(resp), "cannot save HTTP configuration with a redacted secret")
@@ -1509,6 +1509,43 @@ func TestEventActionValidation(t *testing.T) {
 	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
 	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Contains(t, string(resp), "invalid HTTP query parameters")
 	assert.Contains(t, string(resp), "invalid HTTP query parameters")
+	action.Options.HTTPConfig.QueryParameters = nil
+	action.Options.HTTPConfig.Parts = []dataprovider.HTTPPart{
+		{
+			Name: "",
+		},
+	}
+	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
+	assert.NoError(t, err)
+	assert.Contains(t, string(resp), "HTTP part name is required")
+	action.Options.HTTPConfig.Parts = []dataprovider.HTTPPart{
+		{
+			Name: "p1",
+		},
+	}
+	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
+	assert.NoError(t, err)
+	assert.Contains(t, string(resp), "HTTP part body is required if no file path is provided")
+	action.Options.HTTPConfig.Parts = []dataprovider.HTTPPart{
+		{
+			Name:     "p1",
+			Filepath: "p",
+		},
+	}
+	action.Options.HTTPConfig.Body = "b"
+	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
+	assert.NoError(t, err)
+	assert.Contains(t, string(resp), "multipart requests require no body")
+	action.Options.HTTPConfig.Body = ""
+	action.Options.HTTPConfig.Headers = []dataprovider.KeyValue{
+		{
+			Key:   "Content-Type",
+			Value: "application/json",
+		},
+	}
+	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
+	assert.NoError(t, err)
+	assert.Contains(t, string(resp), "content type is automatically set for multipart requests")
 
 
 	action.Type = dataprovider.ActionTypeCommand
 	action.Type = dataprovider.ActionTypeCommand
 	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
 	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
@@ -18899,8 +18936,23 @@ func TestWebEventAction(t *testing.T) {
 	assert.NotEmpty(t, actionGet.Options.HTTPConfig.Password.GetPayload())
 	assert.NotEmpty(t, actionGet.Options.HTTPConfig.Password.GetPayload())
 	assert.Empty(t, actionGet.Options.HTTPConfig.Password.GetKey())
 	assert.Empty(t, actionGet.Options.HTTPConfig.Password.GetKey())
 	assert.Empty(t, actionGet.Options.HTTPConfig.Password.GetAdditionalData())
 	assert.Empty(t, actionGet.Options.HTTPConfig.Password.GetAdditionalData())
-	// update and check that the password is preserved
+	// update and check that the password is preserved and the multipart fields
 	form.Set("http_password", redactedSecret)
 	form.Set("http_password", redactedSecret)
+	form.Set("http_body", "")
+	form.Set("http_timeout", "0")
+	form.Del("http_header_key0")
+	form.Del("http_header_val0")
+	form.Set("http_part_name0", "part1")
+	form.Set("http_part_file0", "{{VirtualPath}}")
+	form.Set("http_part_headers0", "X-MyHeader: a:b,c")
+	form.Set("http_part_body0", "")
+	form.Set("http_part_namea", "ignored")
+	form.Set("http_part_filea", "{{VirtualPath}}")
+	form.Set("http_part_headersa", "X-MyHeader: a:b,c")
+	form.Set("http_part_bodya", "")
+	form.Set("http_part_name12", "part2")
+	form.Set("http_part_headers12", "Content-Type:application/json \r\n")
+	form.Set("http_part_body12", "{{ObjectData}}")
 	req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name),
 	req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name),
 		bytes.NewBuffer([]byte(form.Encode())))
 		bytes.NewBuffer([]byte(form.Encode())))
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -18913,6 +18965,20 @@ func TestWebEventAction(t *testing.T) {
 	err = dbAction.Options.HTTPConfig.Password.Decrypt()
 	err = dbAction.Options.HTTPConfig.Password.Decrypt()
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Equal(t, defaultPassword, dbAction.Options.HTTPConfig.Password.GetPayload())
 	assert.Equal(t, defaultPassword, dbAction.Options.HTTPConfig.Password.GetPayload())
+	assert.Empty(t, dbAction.Options.HTTPConfig.Body)
+	assert.Equal(t, 0, dbAction.Options.HTTPConfig.Timeout)
+	if assert.Len(t, dbAction.Options.HTTPConfig.Parts, 2) {
+		assert.Equal(t, "part1", dbAction.Options.HTTPConfig.Parts[0].Name)
+		assert.Equal(t, "/{{VirtualPath}}", dbAction.Options.HTTPConfig.Parts[0].Filepath)
+		assert.Empty(t, dbAction.Options.HTTPConfig.Parts[0].Body)
+		assert.Equal(t, "X-MyHeader", dbAction.Options.HTTPConfig.Parts[0].Headers[0].Key)
+		assert.Equal(t, "a:b,c", dbAction.Options.HTTPConfig.Parts[0].Headers[0].Value)
+		assert.Equal(t, "part2", dbAction.Options.HTTPConfig.Parts[1].Name)
+		assert.Equal(t, "{{ObjectData}}", dbAction.Options.HTTPConfig.Parts[1].Body)
+		assert.Empty(t, dbAction.Options.HTTPConfig.Parts[1].Filepath)
+		assert.Equal(t, "Content-Type", dbAction.Options.HTTPConfig.Parts[1].Headers[0].Key)
+		assert.Equal(t, "application/json", dbAction.Options.HTTPConfig.Parts[1].Headers[0].Value)
+	}
 	// change action type
 	// change action type
 	action.Type = dataprovider.ActionTypeCommand
 	action.Type = dataprovider.ActionTypeCommand
 	action.Options.CmdConfig = dataprovider.EventActionCommandConfig{
 	action.Options.CmdConfig = dataprovider.EventActionCommandConfig{

+ 42 - 0
internal/httpd/webadmin.go

@@ -23,6 +23,7 @@ import (
 	"net/url"
 	"net/url"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
+	"sort"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 	"time"
 	"time"
@@ -1877,6 +1878,46 @@ func getFoldersRetentionFromPostFields(r *http.Request) ([]dataprovider.FolderRe
 	return res, nil
 	return res, nil
 }
 }
 
 
+func getHTTPPartsFromPostFields(r *http.Request) []dataprovider.HTTPPart {
+	var result []dataprovider.HTTPPart
+	for k := range r.Form {
+		if strings.HasPrefix(k, "http_part_name") {
+			partName := r.Form.Get(k)
+			if partName != "" {
+				idx := strings.TrimPrefix(k, "http_part_name")
+				order, err := strconv.Atoi(idx)
+				if err != nil {
+					continue
+				}
+				filePath := r.Form.Get(fmt.Sprintf("http_part_file%s", idx))
+				body := r.Form.Get(fmt.Sprintf("http_part_body%s", idx))
+				concatHeaders := getSliceFromDelimitedValues(r.Form.Get(fmt.Sprintf("http_part_headers%s", idx)), "\n")
+				var headers []dataprovider.KeyValue
+				for _, h := range concatHeaders {
+					values := strings.SplitN(h, ":", 2)
+					if len(values) > 1 {
+						headers = append(headers, dataprovider.KeyValue{
+							Key:   strings.TrimSpace(values[0]),
+							Value: strings.TrimSpace(values[1]),
+						})
+					}
+				}
+				result = append(result, dataprovider.HTTPPart{
+					Name:     partName,
+					Filepath: filePath,
+					Headers:  headers,
+					Body:     body,
+					Order:    order,
+				})
+			}
+		}
+	}
+	sort.Slice(result, func(i, j int) bool {
+		return result[i].Order < result[j].Order
+	})
+	return result
+}
+
 func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEventActionOptions, error) {
 func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEventActionOptions, error) {
 	httpTimeout, err := strconv.Atoi(r.Form.Get("http_timeout"))
 	httpTimeout, err := strconv.Atoi(r.Form.Get("http_timeout"))
 	if err != nil {
 	if err != nil {
@@ -1913,6 +1954,7 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven
 			Method:          r.Form.Get("http_method"),
 			Method:          r.Form.Get("http_method"),
 			QueryParameters: getKeyValsFromPostFields(r, "http_query_key", "http_query_val"),
 			QueryParameters: getKeyValsFromPostFields(r, "http_query_key", "http_query_val"),
 			Body:            r.Form.Get("http_body"),
 			Body:            r.Form.Get("http_body"),
+			Parts:           getHTTPPartsFromPostFields(r),
 		},
 		},
 		CmdConfig: dataprovider.EventActionCommandConfig{
 		CmdConfig: dataprovider.EventActionCommandConfig{
 			Cmd:     r.Form.Get("cmd_path"),
 			Cmd:     r.Form.Get("cmd_path"),

+ 25 - 1
internal/httpdtest/httpdtest.go

@@ -2208,6 +2208,27 @@ func compareKeyValues(expected, actual []dataprovider.KeyValue) error {
 	return nil
 	return nil
 }
 }
 
 
+func compareHTTPparts(expected, actual []dataprovider.HTTPPart) error {
+	for _, p1 := range expected {
+		found := false
+		for _, p2 := range actual {
+			if p1.Name == p2.Name {
+				found = true
+				if err := compareKeyValues(p1.Headers, p2.Headers); err != nil {
+					return fmt.Errorf("http headers mismatch for part %q", p1.Name)
+				}
+				if p1.Body != p2.Body || p1.Filepath != p2.Filepath {
+					return fmt.Errorf("http part %q mismatch", p1.Name)
+				}
+			}
+		}
+		if !found {
+			return fmt.Errorf("expected http part %q not found", p1.Name)
+		}
+	}
+	return nil
+}
+
 func compareEventActionHTTPConfigFields(expected, actual dataprovider.EventActionHTTPConfig) error {
 func compareEventActionHTTPConfigFields(expected, actual dataprovider.EventActionHTTPConfig) error {
 	if expected.Endpoint != actual.Endpoint {
 	if expected.Endpoint != actual.Endpoint {
 		return errors.New("http endpoint mismatch")
 		return errors.New("http endpoint mismatch")
@@ -2236,7 +2257,10 @@ func compareEventActionHTTPConfigFields(expected, actual dataprovider.EventActio
 	if expected.Body != actual.Body {
 	if expected.Body != actual.Body {
 		return errors.New("http body mismatch")
 		return errors.New("http body mismatch")
 	}
 	}
-	return nil
+	if len(expected.Parts) != len(actual.Parts) {
+		return errors.New("http parts mismatch")
+	}
+	return compareHTTPparts(expected.Parts, actual.Parts)
 }
 }
 
 
 func compareEventActionEmailConfigFields(expected, actual dataprovider.EventActionEmailConfig) error {
 func compareEventActionEmailConfigFields(expected, actual dataprovider.EventActionEmailConfig) error {

+ 1 - 1
internal/version/version.go

@@ -17,7 +17,7 @@ package version
 
 
 import "strings"
 import "strings"
 
 
-const version = "2.3.3-dev"
+const version = "2.3.4-dev"
 
 
 var (
 var (
 	commit = ""
 	commit = ""

+ 22 - 1
openapi/openapi.yaml

@@ -27,7 +27,7 @@ info:
     SFTPGo supports groups to simplify the administration of multiple accounts by letting you assign settings once to a group, instead of multiple times to each individual user.
     SFTPGo supports groups to simplify the administration of multiple accounts by letting you assign settings once to a group, instead of multiple times to each individual user.
     The SFTPGo WebClient allows end users to change their credentials, browse and manage their files in the browser and setup two-factor authentication which works with Authy, Google Authenticator and other compatible apps.
     The SFTPGo WebClient allows end users to change their credentials, browse and manage their files in the browser and setup two-factor authentication which works with Authy, Google Authenticator and other compatible apps.
     From the WebClient each authorized user can also create HTTP/S links to externally share files and folders securely, by setting limits to the number of downloads/uploads, protecting the share with a password, limiting access by source IP address, setting an automatic expiration date.
     From the WebClient each authorized user can also create HTTP/S links to externally share files and folders securely, by setting limits to the number of downloads/uploads, protecting the share with a password, limiting access by source IP address, setting an automatic expiration date.
-  version: 2.3.3-dev
+  version: 2.3.4-dev
   contact:
   contact:
     name: API support
     name: API support
     url: 'https://github.com/drakkan/sftpgo'
     url: 'https://github.com/drakkan/sftpgo'
@@ -6013,6 +6013,21 @@ components:
           type: string
           type: string
         value:
         value:
           type: string
           type: string
+    HTTPPart:
+      type: object
+      properties:
+        name:
+          type: string
+        headers:
+          type: array
+          items:
+            $ref: '#/components/schemas/KeyValue'
+          description: 'Additional headers. Content-Disposition header is automatically set. Content-Type header is automatically detect for files to attach'
+        filepath:
+          type: string
+          description: 'path to the file to be sent as an attachment'
+        body:
+          type: string
     EventActionHTTPConfig:
     EventActionHTTPConfig:
       type: object
       type: object
       properties:
       properties:
@@ -6033,6 +6048,7 @@ components:
           type: integer
           type: integer
           minimum: 1
           minimum: 1
           maximum: 120
           maximum: 120
+          description: 'Ignored for multipart requests with files as attachments'
         skip_tls_verify:
         skip_tls_verify:
           type: boolean
           type: boolean
           description: 'if enabled the HTTP client accepts any TLS certificate presented by the server and any host name in that certificate. In this mode, TLS is susceptible to man-in-the-middle attacks. This should be used only for testing.'
           description: 'if enabled the HTTP client accepts any TLS certificate presented by the server and any host name in that certificate. In this mode, TLS is susceptible to man-in-the-middle attacks. This should be used only for testing.'
@@ -6049,6 +6065,11 @@ components:
         body:
         body:
           type: string
           type: string
           description: HTTP POST/PUT body
           description: HTTP POST/PUT body
+        parts:
+          type: array
+          items:
+            $ref: '#/components/schemas/HTTPPart'
+          description: 'Multipart requests allow to combine one or more sets of data into a single body. For each part, you can set a file path or a body as text. Placeholders are supported in file path, body, header values.'
     EventActionCommandConfig:
     EventActionCommandConfig:
       type: object
       type: object
       properties:
       properties:

+ 4 - 4
pkgs/choco/sftpgo.nuspec

@@ -3,17 +3,17 @@
 <package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
 <package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
 	<metadata>
 	<metadata>
 		<id>sftpgo</id>
 		<id>sftpgo</id>
-		<version>2.3.3</version>
+		<version>2.3.4</version>
 		<packageSourceUrl>https://github.com/drakkan/sftpgo/tree/main/pkgs/choco</packageSourceUrl>
 		<packageSourceUrl>https://github.com/drakkan/sftpgo/tree/main/pkgs/choco</packageSourceUrl>
 		<owners>asheroto</owners>
 		<owners>asheroto</owners>
 		<title>SFTPGo</title>
 		<title>SFTPGo</title>
 		<authors>Nicola Murino</authors>
 		<authors>Nicola Murino</authors>
 		<projectUrl>https://github.com/drakkan/sftpgo</projectUrl>
 		<projectUrl>https://github.com/drakkan/sftpgo</projectUrl>
-		<iconUrl>https://cdn.statically.io/gh/drakkan/sftpgo/v2.3.3/static/img/logo.png</iconUrl>
+		<iconUrl>https://cdn.statically.io/gh/drakkan/sftpgo/v2.3.4/static/img/logo.png</iconUrl>
 		<licenseUrl>https://github.com/drakkan/sftpgo/blob/main/LICENSE</licenseUrl>
 		<licenseUrl>https://github.com/drakkan/sftpgo/blob/main/LICENSE</licenseUrl>
 		<requireLicenseAcceptance>false</requireLicenseAcceptance>
 		<requireLicenseAcceptance>false</requireLicenseAcceptance>
 		<projectSourceUrl>https://github.com/drakkan/sftpgo</projectSourceUrl>
 		<projectSourceUrl>https://github.com/drakkan/sftpgo</projectSourceUrl>
-		<docsUrl>https://github.com/drakkan/sftpgo/tree/v2.3.3/docs</docsUrl>
+		<docsUrl>https://github.com/drakkan/sftpgo/tree/v2.3.4/docs</docsUrl>
 		<bugTrackerUrl>https://github.com/drakkan/sftpgo/issues</bugTrackerUrl>
 		<bugTrackerUrl>https://github.com/drakkan/sftpgo/issues</bugTrackerUrl>
 		<tags>sftp sftp-server ftp webdav s3 azure-blob google-cloud-storage cloud-storage scp data-at-rest-encryption multi-factor-authentication multi-step-authentication</tags>
 		<tags>sftp sftp-server ftp webdav s3 azure-blob google-cloud-storage cloud-storage scp data-at-rest-encryption multi-factor-authentication multi-step-authentication</tags>
 		<summary>Fully featured and highly configurable SFTP server with optional HTTP/S,FTP/S and WebDAV support.</summary>
 		<summary>Fully featured and highly configurable SFTP server with optional HTTP/S,FTP/S and WebDAV support.</summary>
@@ -32,7 +32,7 @@ You can find more info [here](https://github.com/drakkan/sftpgo).
 
 
 * This package installs SFTPGo as Windows Service.
 * This package installs SFTPGo as Windows Service.
 * After the first installation please take a look at the [Getting Started Guide](https://github.com/drakkan/sftpgo/blob/main/docs/howto/getting-started.md).</description>
 * After the first installation please take a look at the [Getting Started Guide](https://github.com/drakkan/sftpgo/blob/main/docs/howto/getting-started.md).</description>
-		<releaseNotes>https://github.com/drakkan/sftpgo/releases/tag/v2.3.3</releaseNotes>
+		<releaseNotes>https://github.com/drakkan/sftpgo/releases/tag/v2.3.4</releaseNotes>
 	</metadata>
 	</metadata>
 	<files>
 	<files>
 		<file src="**" exclude="**\*.md;**\icon.png;**\icon.jpg;**\icon.svg" />
 		<file src="**" exclude="**\*.md;**\icon.png;**\icon.jpg;**\icon.svg" />

+ 4 - 4
pkgs/choco/tools/ChocolateyInstall.ps1

@@ -1,8 +1,8 @@
 $ErrorActionPreference  = 'Stop'
 $ErrorActionPreference  = 'Stop'
 $packageName    = 'sftpgo'
 $packageName    = 'sftpgo'
 $softwareName   = 'SFTPGo'
 $softwareName   = 'SFTPGo'
-$url            = 'https://github.com/drakkan/sftpgo/releases/download/v2.3.3/sftpgo_v2.3.3_windows_x86_64.exe'
-$checksum       = '5A6E798BDD920D7DE6110C8478A993C9E5A691E48F74D86021DF4745CB8A5FDC'
+$url            = 'https://github.com/drakkan/sftpgo/releases/download/v2.3.4/sftpgo_v2.3.4_windows_x86_64.exe'
+$checksum       = '68428CECD98DB2F111BB5B1293CF7807BA8DA2CEFDD7F38ACDCF7B7D50C781DC'
 $silentArgs     = '/VERYSILENT'
 $silentArgs     = '/VERYSILENT'
 $validExitCodes = @(0)
 $validExitCodes = @(0)
 
 
@@ -45,8 +45,8 @@ Write-Output ""
 Write-Output "General information (README) location:"
 Write-Output "General information (README) location:"
 Write-Output "`thttps://github.com/drakkan/sftpgo"
 Write-Output "`thttps://github.com/drakkan/sftpgo"
 Write-Output "Getting start guide location:"
 Write-Output "Getting start guide location:"
-Write-Output "`thttps://github.com/drakkan/sftpgo/blob/v2.3.3/docs/howto/getting-started.md"
+Write-Output "`thttps://github.com/drakkan/sftpgo/blob/v2.3.4/docs/howto/getting-started.md"
 Write-Output "Detailed information (docs folder) location:"
 Write-Output "Detailed information (docs folder) location:"
-Write-Output "`thttps://github.com/drakkan/sftpgo/tree/v2.3.3/docs"
+Write-Output "`thttps://github.com/drakkan/sftpgo/tree/v2.3.4/docs"
 Write-Output ""
 Write-Output ""
 Write-Output "---------------------------"
 Write-Output "---------------------------"

+ 6 - 0
pkgs/debian/changelog

@@ -1,3 +1,9 @@
+sftpgo (2.3.4-1ppa1) bionic; urgency=medium
+
+  * New upstream release
+
+ -- Nicola Murino <nicola.murino@gmail.com>  Thu, 01 Sep 2022 16:56:20 +0200
+
 sftpgo (2.3.3-1ppa1) bionic; urgency=medium
 sftpgo (2.3.3-1ppa1) bionic; urgency=medium
 
 
   * New upstream release
   * New upstream release

+ 142 - 12
templates/webadmin/eventaction.html

@@ -213,22 +213,14 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 </div>
                 </div>
             </div>
             </div>
 
 
-            <div class="form-group row action-type action-http">
-                <label for="idHTTPBody" class="col-sm-2 col-form-label">Body</label>
-                <div class="col-sm-10">
-                    <textarea class="form-control" id="idHTTPBody" name="http_body" rows="4" placeholder=""
-                        aria-describedby="httpBodyHelpBlock">{{.Action.Options.HTTPConfig.Body}}</textarea>
-                    <small id="httpBodyHelpBlock" class="form-text text-muted">
-                        Placeholders are supported
-                    </small>
-                </div>
-            </div>
-
             <div class="form-group row action-type action-http">
             <div class="form-group row action-type action-http">
                 <label for="idHTTPTimeout" class="col-sm-2 col-form-label">Timeout</label>
                 <label for="idHTTPTimeout" class="col-sm-2 col-form-label">Timeout</label>
                 <div class="col-sm-10">
                 <div class="col-sm-10">
                     <input type="number" min="1" max="120" class="form-control" id="idHTTPTimeout" name="http_timeout" placeholder=""
                     <input type="number" min="1" max="120" class="form-control" id="idHTTPTimeout" name="http_timeout" placeholder=""
-                        value="{{.Action.Options.HTTPConfig.Timeout}}">
+                        aria-describedby="httpTimeoutHelpBlock" value="{{.Action.Options.HTTPConfig.Timeout}}">
+                    <small id="httpTimeoutHelpBlock" class="form-text text-muted">
+                        Ignored for multipart requests with files as attachments.
+                    </small>
                 </div>
                 </div>
             </div>
             </div>
 
 
@@ -240,6 +232,100 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 </div>
                 </div>
             </div>
             </div>
 
 
+            <div class="form-group row action-type action-http">
+                <label for="idHTTPBody" class="col-sm-2 col-form-label">Body</label>
+                <div class="col-sm-10">
+                    <textarea class="form-control" id="idHTTPBody" name="http_body" rows="4" placeholder=""
+                        aria-describedby="httpBodyHelpBlock">{{.Action.Options.HTTPConfig.Body}}</textarea>
+                    <small id="httpBodyHelpBlock" class="form-text text-muted">
+                        Placeholders are supported. Ignored for HTTP get requested. Leave empty for multipart requests.
+                    </small>
+                </div>
+            </div>
+
+            <div class="card bg-light mb-3 action-type action-http">
+                <div class="card-header">
+                    <b>Multipart body</b>
+                </div>
+                <div class="card-body">
+                    <h6 class="card-title mb-4">Multipart requests allow to combine one or more sets of data into a single body. For each part, you can set a file path or a body as text. Placeholders are supported in file path, body, header values.</h6>
+                    <div class="form-group row">
+                        <div class="col-md-12 form_field_http_part_outer">
+                            {{range $idx, $val := .Action.Options.HTTPConfig.Parts}}
+                            <div class="row form_field_http_part_outer_row">
+                                <div class="col-md-12">
+                                    <div class="row">
+                                        <div class="form-group col-md-2">
+                                            <input type="text" class="form-control" id="idHTTPPartName{{$idx}}" name="http_part_name{{$idx}}" placeholder="Part name" value="{{$val.Name}}">
+                                        </div>
+                                        <div class="form-group col-md-3">
+                                            <input type="text" class="form-control" id="idHTTPPartFile{{$idx}}" name="http_part_file{{$idx}}" placeholder="File path" value="{{$val.Filepath}}">
+                                        </div>
+                                        <div class="form-group col-md-5">
+                                            <textarea class="form-control" id="idHTTPPartHeaders{{$idx}}" name="http_part_headers{{$idx}}" rows="2" placeholder="Additional part headers"
+                                                            aria-describedby="httpPartHeadersHelpBlock{{$idx}}">{{range $val.Headers}}{{.Key}}: {{.Value}}&#010;{{end}}</textarea>
+                                            <small id="httpPartHeadersHelpBlock{{$idx}}" class="form-text text-muted">
+                                                One header per line as "key: value", example: "Content-Type: application/json", without quotes. Content type for files is automatically detected
+                                            </small>
+                                        </div>
+                                        <div class="form-group col-md-1"></div>
+                                        <div class="form-group col-md-1">
+                                            <button class="btn btn-circle btn-danger remove_http_part_btn_frm_field">
+                                                <i class="fas fa-trash"></i>
+                                            </button>
+                                        </div>
+                                    </div>
+                                    <div class="row">
+                                        <div class="form-group col-md-10">
+                                            <textarea class="form-control" id="idHTTPPartBody{{$idx}}" name="http_part_body{{$idx}}" rows="3" placeholder="Part body">{{$val.Body}}</textarea>
+                                        </div>
+                                    </div>
+                                    <hr>
+                                </div>
+                            </div>
+                            {{else}}
+                            <div class="row form_field_http_part_outer_row">
+                                <div class="col-md-12">
+                                    <div class="row">
+                                        <div class="form-group col-md-2">
+                                            <input type="text" class="form-control" id="idHTTPPartName0" name="http_part_name0" placeholder="Part name" value="">
+                                        </div>
+                                        <div class="form-group col-md-3">
+                                            <input type="text" class="form-control" id="idHTTPPartFile0" name="http_part_file0" placeholder="File path" value="">
+                                        </div>
+                                        <div class="form-group col-md-5">
+                                            <textarea class="form-control" id="idHTTPPartHeaders0" name="http_part_headers0" rows="2" placeholder="Additional part headers"
+                                                            aria-describedby="httpPartHeadersHelpBlock0"></textarea>
+                                            <small id="httpPartHeadersHelpBlock0" class="form-text text-muted">
+                                                One header per line as "key: value", example: "Content-Type: application/json", without quotes. Content type for files is automatically detected
+                                            </small>
+                                        </div>
+                                        <div class="form-group col-md-1"></div>
+                                        <div class="form-group col-md-1">
+                                            <button class="btn btn-circle btn-danger remove_http_part_btn_frm_field">
+                                                <i class="fas fa-trash"></i>
+                                            </button>
+                                        </div>
+                                    </div>
+                                    <div class="row">
+                                        <div class="form-group col-md-10">
+                                            <textarea class="form-control" id="idHTTPPartBody0" name="http_part_body0" rows="3" placeholder="Part body"></textarea>
+                                        </div>
+                                    </div>
+                                    <hr>
+                                </div>
+                            </div>
+                            {{end}}
+                        </div>
+                    </div>
+                    <div class="row mx-1">
+                        <button type="button" class="btn btn-secondary add_new_http_part_field_btn">
+                            <i class="fas fa-plus"></i> Add new HTTP part
+                        </button>
+                    </div>
+                </div>
+            </div>
+
             <div class="form-group row action-type action-cmd">
             <div class="form-group row action-type action-cmd">
                 <label for="idCmdPath" class="col-sm-2 col-form-label">Command</label>
                 <label for="idCmdPath" class="col-sm-2 col-form-label">Command</label>
                 <div class="col-sm-10">
                 <div class="col-sm-10">
@@ -745,6 +831,50 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
         $(this).closest(".form_field_fs_rename_outer_row").remove();
         $(this).closest(".form_field_fs_rename_outer_row").remove();
     });
     });
 
 
+    $("body").on("click", ".add_new_http_part_field_btn", function () {
+        var index = $(".form_field_http_part_outer").find(".form_field_http_part_outer_row").length;
+        while (document.getElementById("idHTTPPartName"+index) != null){
+            index++;
+        }
+        $(".form_field_http_part_outer").append(`
+            <div class="row form_field_http_part_outer_row">
+                <div class="col-md-12">
+                    <div class="row">
+                        <div class="form-group col-md-2">
+                            <input type="text" class="form-control" id="idHTTPPartName${index}" name="http_part_name${index}" placeholder="Part name" value="">
+                        </div>
+                        <div class="form-group col-md-3">
+                            <input type="text" class="form-control" id="idHTTPPartFile${index}" name="http_part_file${index}" placeholder="File path" value="">
+                        </div>
+                        <div class="form-group col-md-5">
+                            <textarea class="form-control" id="idHTTPPartHeaders${index}" name="http_part_headers${index}" rows="2" placeholder="Additional part headers"
+                                        aria-describedby="httpPartHeadersHelpBlock${index}"></textarea>
+                            <small id="httpPartHeadersHelpBlock${index}" class="form-text text-muted">
+                                One header per line as "key: value", example: "Content-Type: application/json", without quotes. Content type for files is automatically detected
+                            </small>
+                        </div>
+                        <div class="form-group col-md-1"></div>
+                        <div class="form-group col-md-1">
+                            <button class="btn btn-circle btn-danger remove_http_part_btn_frm_field">
+                                <i class="fas fa-trash"></i>
+                            </button>
+                        </div>
+                    </div>
+                    <div class="row">
+                        <div class="form-group col-md-10">
+                            <textarea class="form-control" id="idHTTPPartBody${index}" name="http_part_body${index}" rows="3" placeholder="Part body"></textarea>
+                        </div>
+                    </div>
+                    <hr>
+                </div>
+            </div>
+            `);
+        });
+
+    $("body").on("click", ".remove_http_part_btn_frm_field", function () {
+        $(this).closest(".form_field_http_part_outer_row").remove();
+    });
+
     function onTypeChanged(val){
     function onTypeChanged(val){
         $('.action-type').hide();
         $('.action-type').hide();
         switch (val) {
         switch (val) {

+ 1 - 1
templates/webadmin/fsconfig.html

@@ -455,7 +455,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
             <label for="idSFTPFingerprints" class="col-sm-2 col-form-label">Fingerprints</label>
             <label for="idSFTPFingerprints" class="col-sm-2 col-form-label">Fingerprints</label>
             <div class="col-sm-10">
             <div class="col-sm-10">
                 <textarea class="form-control" id="idSFTPFingerprints" name="sftp_fingerprints" rows="3"
                 <textarea class="form-control" id="idSFTPFingerprints" name="sftp_fingerprints" rows="3"
-                    aria-describedby="SFTPFingerprintsHelpBlock">{{range .SFTPConfig.Fingerprints}}{{.}}&#10;{{end}}</textarea>
+                    aria-describedby="SFTPFingerprintsHelpBlock">{{range .SFTPConfig.Fingerprints}}{{.}}&#010;{{end}}</textarea>
                 <small id="SFTPFingerprintsHelpBlock" class="form-text text-muted">
                 <small id="SFTPFingerprintsHelpBlock" class="form-text text-muted">
                     SHA256 fingerprints to validate when connecting to the external SFTP server, one per line. If empty any host key will be accepted: this is a security risk!
                     SHA256 fingerprints to validate when connecting to the external SFTP server, one per line. If empty any host key will be accepted: this is a security risk!
                 </small>
                 </small>