Browse Source

http actions: add multipart support

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 2 years ago
parent
commit
c2a65a9a74

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

@@ -423,6 +423,12 @@ jobs:
           gzip output/man/man1/*
           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
         if: ${{ matrix.arch != 'amd64' }}
         name: Build for ${{ matrix.arch }}
@@ -437,7 +443,7 @@ jobs:
           shell: /bin/bash
           install: |
             apt-get update -q -y
-            apt-get install -q -y curl gcc git
+            apt-get install -q -y curl gcc
             if [ ${{ matrix.go }} == 'latest' ]
             then
               GO_VERSION=$(curl -L https://go.dev/VERSION?m=text)
@@ -457,7 +463,7 @@ jobs:
             then
               export GOARM=7
             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}
             cp sftpgo.json output/
             cp -r templates output/

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

@@ -95,6 +95,13 @@ jobs:
                 fi
                 TAGS="${TAGS},${DOCKER_IMAGE}:distroless"
                 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
                 if [[ -n $MAJOR && -n $MINOR ]]; then
                   TAGS="${TAGS},${DOCKER_IMAGE}:${MINOR}-alpine,${DOCKER_IMAGE}:${MAJOR}-alpine"

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

@@ -326,6 +326,12 @@ jobs:
         env:
           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
         if: ${{ matrix.arch != 'amd64' }}
         name: Build for ${{ matrix.arch }}
@@ -340,7 +346,7 @@ jobs:
           shell: /bin/bash
           install: |
             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 }}
             if [ ${{ matrix.arch}} == 'armv7' ]
             then
@@ -350,7 +356,7 @@ jobs:
             tar -C /usr/local -xzf go.tar.gz
           run: |
             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}
             echo "For documentation please take a look here:" > 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
 
-- [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-plugins](../Dockerfile)
 - [edge-alpine](../Dockerfile.alpine)

+ 26 - 27
go.mod

@@ -4,21 +4,21 @@ go 1.19
 
 require (
 	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/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	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/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/fclairamb/ftpserverlib v0.19.1
 	github.com/fclairamb/go-log v0.4.1
@@ -44,7 +44,7 @@ require (
 	github.com/minio/sio v0.3.0
 	github.com/otiai10/copy v1.7.0
 	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/prometheus/client_golang v1.13.0
 	github.com/robfig/cron/v3 v3.0.1
@@ -52,7 +52,7 @@ require (
 	github.com/rs/xid v1.4.0
 	github.com/rs/zerolog v1.28.0
 	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/cobra v1.5.0
 	github.com/spf13/viper v1.12.0
@@ -80,18 +80,18 @@ require (
 	cloud.google.com/go/iam v0.3.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.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/boombuler/barcode v1.0.1 // 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/xerrors v0.0.0-20220609144429-65e65417b02f // 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/protobuf v1.28.1 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
@@ -167,7 +167,6 @@ require (
 
 replace (
 	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/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/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 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 v1.0.0 h1:Yoicul8bnVdQrhDMTHxdEckRGX01XvwXDHUT9zYZ3k0=
 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.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.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.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.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.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.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.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.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.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.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.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.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.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.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/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.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.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/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/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.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.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/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=
@@ -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/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/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-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 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/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/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/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84=
 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.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 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/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/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 v1.2.0/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-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-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-20210510120138-977fb7262007/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-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-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.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 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/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
 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/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 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"
 	"fmt"
 	"io"
+	"mime"
+	"mime/multipart"
 	"net/http"
+	"net/textproto"
 	"net/url"
 	"os"
 	"os/exec"
@@ -49,7 +52,8 @@ const (
 
 var (
 	// eventManager handle the supported event rules actions
-	eventManager eventRulesContainer
+	eventManager          eventRulesContainer
+	multipartQuoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
 )
 
 func init() {
@@ -455,7 +459,12 @@ func (p *EventParams) getStatusString() string {
 // getUsers returns users with group settings not applied
 func (p *EventParams) getUsers() ([]dataprovider.User, error) {
 	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()
 	if err != nil {
@@ -467,7 +476,8 @@ func (p *EventParams) getUsers() ([]dataprovider.User, error) {
 func (p *EventParams) getUserFromSender() (dataprovider.User, error) {
 	user, err := dataprovider.UserExists(p.sender)
 	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
 }
@@ -515,26 +525,45 @@ func (p *EventParams) getStringReplacements(addObjectData bool) []string {
 	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)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	f, r, cancelFn, err := fs.Open(fsPath, 0)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	if cancelFn == nil {
 		cancelFn = func() {}
 	}
-	defer cancelFn()
 
-	var reader io.ReadCloser
 	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()
 
 	data := make([]byte, expectedSize)
@@ -632,19 +661,103 @@ func getHTTPRuleActionEndpoint(c dataprovider.EventActionHTTPConfig, replacer *s
 	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)
@@ -654,16 +767,32 @@ func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params *EventPa
 		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 {
 		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 != "" {
-		req.SetBasicAuth(replaceWithReplacer(c.Username, replacer), c.Password.GetAdditionalData())
+		req.SetBasicAuth(replaceWithReplacer(c.Username, replacer), c.Password.GetPayload())
 	}
 	for _, keyVal := range c.Headers {
 		req.Header.Set(keyVal.Key, replaceWithReplacer(keyVal.Value, replacer))
@@ -676,11 +805,11 @@ func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params *EventPa
 	if err != nil {
 		eventManagerLog(logger.LevelDebug, "unable to send http notification, endpoint: %s, elapsed: %s, err: %v",
 			endpoint, time.Since(startTime), err)
-		return err
+		return fmt.Errorf("error sending HTTP request: %w", err)
 	}
 	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)
 	if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusNoContent {
 		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) {
 	err := user.LoadAndApplyGroupSettings()
 	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.FilePatterns = nil
 	for k := range user.Permissions {
 		user.Permissions[k] = []string{dataprovider.PermAny}
 	}
-	return user, err
+	return user, nil
 }
 
 func executeDeleteFileFsAction(conn *BaseConnection, item string, info os.FileInfo) error {

+ 90 - 2
internal/common/eventmanager_test.go

@@ -17,6 +17,8 @@ package common
 import (
 	"crypto/rand"
 	"fmt"
+	"io"
+	"mime/multipart"
 	"net/http"
 	"os"
 	"path"
@@ -349,6 +351,26 @@ func TestEventManagerErrors(t *testing.T) {
 		}}, []string{"/a", "/b"}, nil)
 	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{
 		Type: dataprovider.ActionTypeDataRetentionCheck,
 		Options: dataprovider.BaseEventActionOptions{
@@ -469,6 +491,9 @@ func TestEventRuleActions(t *testing.T) {
 	}
 	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
 	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)
 	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
 	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")
 	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
 	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
 	username1 := "user1"
 	username2 := "user2"
@@ -849,6 +888,12 @@ func TestEventRuleActions(t *testing.T) {
 		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)
 	assert.NoError(t, err)
 	err = dataprovider.DeleteFolder(foldername1, "", "")
@@ -979,7 +1024,19 @@ func TestFilesystemActionErrors(t *testing.T) {
 	assert.Error(t, err)
 	_, err = getFileContent(NewBaseConnection("", protocolEventAction, "", "", user), "/f.txt", 1234)
 	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.Permissions["/"] = []string{dataprovider.PermUpload}
 	err = dataprovider.DeleteUser(username, "", "")
@@ -1276,3 +1333,34 @@ func TestEventParamsStatusFromError(t *testing.T) {
 	params.AddError(os.ErrNotExist)
 	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)
 			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 {
 			logger.ErrorToConsole("could not start HTTP notification server: %v", err)
 			os.Exit(1)
@@ -3815,6 +3825,94 @@ func TestEventRuleFsActions(t *testing.T) {
 	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) {
 	smtpCfg := smtp.Config{
 		Host:          "127.0.0.1",

+ 126 - 8
internal/dataprovider/eventrule.go

@@ -15,6 +15,7 @@
 package dataprovider
 
 import (
+	"context"
 	"crypto/tls"
 	"encoding/json"
 	"errors"
@@ -200,6 +201,39 @@ type KeyValue struct {
 	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
 type EventActionHTTPConfig struct {
 	Endpoint        string      `json:"endpoint,omitempty"`
@@ -210,7 +244,34 @@ type EventActionHTTPConfig struct {
 	SkipTLSVerify   bool        `json:"skip_tls_verify,omitempty"`
 	Method          string      `json:"method,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 {
@@ -220,14 +281,17 @@ func (c *EventActionHTTPConfig) validate(additionalData string) error {
 	if !util.IsStringPrefixInSlice(c.Endpoint, []string{"http://", "https://"}) {
 		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))
 	}
 	for _, kv := range c.Headers {
-		if kv.Key == "" || kv.Value == "" {
+		if kv.isNotValid() {
 			return util.NewValidationError("invalid HTTP headers")
 		}
 	}
+	if err := c.validateMultiparts(); err != nil {
+		return err
+	}
 	if c.Password.IsRedacted() {
 		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))
 	}
 	for _, kv := range c.QueryParameters {
-		if kv.Key == "" || kv.Value == "" {
+		if kv.isNotValid() {
 			return util.NewValidationError("invalid HTTP query parameters")
 		}
 	}
 	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
 func (c *EventActionHTTPConfig) GetHTTPClient() *http.Client {
-	client := &http.Client{
-		Timeout: time.Duration(c.Timeout) * time.Second,
-	}
+	client := &http.Client{}
 	if c.SkipTLSVerify {
 		transport := http.DefaultTransport.(*http.Transport).Clone()
 		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))
 	}
 	for _, kv := range c.EnvVars {
-		if kv.Key == "" || kv.Value == "" {
+		if kv.isNotValid() {
 			return util.NewValidationError("invalid command env vars")
 		}
 	}
@@ -589,6 +692,15 @@ func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions {
 			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{
 		HTTPConfig: EventActionHTTPConfig{
@@ -601,6 +713,7 @@ func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions {
 			Method:          o.HTTPConfig.Method,
 			QueryParameters: cloneKeyValues(o.HTTPConfig.QueryParameters),
 			Body:            o.HTTPConfig.Body,
+			Parts:           httpParts,
 		},
 		CmdConfig: EventActionCommandConfig{
 			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")
 			}
 		}
+		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
 }

+ 68 - 2
internal/httpd/httpd_test.go

@@ -1490,7 +1490,7 @@ func TestEventActionValidation(t *testing.T) {
 			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)
 	assert.NoError(t, err)
 	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)
 	assert.NoError(t, err)
 	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
 	_, 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.Empty(t, actionGet.Options.HTTPConfig.Password.GetKey())
 	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_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),
 		bytes.NewBuffer([]byte(form.Encode())))
 	assert.NoError(t, err)
@@ -18913,6 +18965,20 @@ func TestWebEventAction(t *testing.T) {
 	err = dbAction.Options.HTTPConfig.Password.Decrypt()
 	assert.NoError(t, err)
 	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
 	action.Type = dataprovider.ActionTypeCommand
 	action.Options.CmdConfig = dataprovider.EventActionCommandConfig{

+ 42 - 0
internal/httpd/webadmin.go

@@ -23,6 +23,7 @@ import (
 	"net/url"
 	"os"
 	"path/filepath"
+	"sort"
 	"strconv"
 	"strings"
 	"time"
@@ -1877,6 +1878,46 @@ func getFoldersRetentionFromPostFields(r *http.Request) ([]dataprovider.FolderRe
 	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) {
 	httpTimeout, err := strconv.Atoi(r.Form.Get("http_timeout"))
 	if err != nil {
@@ -1913,6 +1954,7 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven
 			Method:          r.Form.Get("http_method"),
 			QueryParameters: getKeyValsFromPostFields(r, "http_query_key", "http_query_val"),
 			Body:            r.Form.Get("http_body"),
+			Parts:           getHTTPPartsFromPostFields(r),
 		},
 		CmdConfig: dataprovider.EventActionCommandConfig{
 			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
 }
 
+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 {
 	if expected.Endpoint != actual.Endpoint {
 		return errors.New("http endpoint mismatch")
@@ -2236,7 +2257,10 @@ func compareEventActionHTTPConfigFields(expected, actual dataprovider.EventActio
 	if expected.Body != actual.Body {
 		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 {

+ 1 - 1
internal/version/version.go

@@ -17,7 +17,7 @@ package version
 
 import "strings"
 
-const version = "2.3.3-dev"
+const version = "2.3.4-dev"
 
 var (
 	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.
     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.
-  version: 2.3.3-dev
+  version: 2.3.4-dev
   contact:
     name: API support
     url: 'https://github.com/drakkan/sftpgo'
@@ -6013,6 +6013,21 @@ components:
           type: string
         value:
           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:
       type: object
       properties:
@@ -6033,6 +6048,7 @@ components:
           type: integer
           minimum: 1
           maximum: 120
+          description: 'Ignored for multipart requests with files as attachments'
         skip_tls_verify:
           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.'
@@ -6049,6 +6065,11 @@ components:
         body:
           type: string
           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:
       type: object
       properties:

+ 4 - 4
pkgs/choco/sftpgo.nuspec

@@ -3,17 +3,17 @@
 <package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
 	<metadata>
 		<id>sftpgo</id>
-		<version>2.3.3</version>
+		<version>2.3.4</version>
 		<packageSourceUrl>https://github.com/drakkan/sftpgo/tree/main/pkgs/choco</packageSourceUrl>
 		<owners>asheroto</owners>
 		<title>SFTPGo</title>
 		<authors>Nicola Murino</authors>
 		<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>
 		<requireLicenseAcceptance>false</requireLicenseAcceptance>
 		<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>
 		<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>
@@ -32,7 +32,7 @@ You can find more info [here](https://github.com/drakkan/sftpgo).
 
 * 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>
-		<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>
 	<files>
 		<file src="**" exclude="**\*.md;**\icon.png;**\icon.jpg;**\icon.svg" />

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

@@ -1,8 +1,8 @@
 $ErrorActionPreference  = 'Stop'
 $packageName    = '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'
 $validExitCodes = @(0)
 
@@ -45,8 +45,8 @@ Write-Output ""
 Write-Output "General information (README) location:"
 Write-Output "`thttps://github.com/drakkan/sftpgo"
 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 "`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 "---------------------------"

+ 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
 
   * 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 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">
                 <label for="idHTTPTimeout" class="col-sm-2 col-form-label">Timeout</label>
                 <div class="col-sm-10">
                     <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>
 
@@ -240,6 +232,100 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 </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">
                 <label for="idCmdPath" class="col-sm-2 col-form-label">Command</label>
                 <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();
     });
 
+    $("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){
         $('.action-type').hide();
         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>
             <div class="col-sm-10">
                 <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">
                     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>