Jelajahi Sumber

OpenAPI: add users API

These new APIs match the web client features.

I'm aware that some API do not follow REST best practises.

I want to avoid things likes "/user/folders/<path>"

where "path" must be encoded and making it optional create issues, so
I defined resources as query parameters instead of path parameters
Nicola Murino 4 tahun lalu
induk
melakukan
43182fc25e

+ 6 - 6
.github/workflows/development.yml

@@ -31,18 +31,18 @@ jobs:
 
       - name: Build for Linux/macOS x86_64
         if: startsWith(matrix.os, 'windows-') != true
-        run: go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo
+        run: go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo
 
       - name: Build for macOS arm64
         if: startsWith(matrix.os, 'macos-') == true
-        run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64
+        run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64
 
       - name: Build for Windows
         if: startsWith(matrix.os, 'windows-')
         run: |
           $GIT_COMMIT = (git describe --always --dirty) | Out-String
           $DATE_TIME = ([datetime]::Now.ToUniversalTime().toString("yyyy-MM-ddTHH:mm:ssZ")) | Out-String
-          go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/version.date=$DATE_TIME" -o sftpgo.exe
+          go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/version.date=$DATE_TIME" -o sftpgo.exe
 
       - name: Run test cases using SQLite provider
         run: go test -v -p 1 -timeout 10m ./... -coverprofile=coverage.txt -covermode=atomic
@@ -148,7 +148,7 @@ jobs:
           go-version: 1.16
 
       - name: Build
-        run: go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo
+        run: go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo
 
       - name: Run tests using PostgreSQL provider
         run: |
@@ -216,7 +216,7 @@ jobs:
       - name: Build on amd64
         if: ${{ matrix.arch == 'amd64' }}
         run: |
-          go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo
+          go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo
           mkdir -p output/{init,bash_completion,zsh_completion}
           cp sftpgo.json output/
           cp -r templates output/
@@ -253,7 +253,7 @@ jobs:
             tar -C /usr/local -xzf go.tar.gz
           run: |
             export PATH=$PATH:/usr/local/go/bin
-            go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo
+            go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo
             mkdir -p output/{init,bash_completion,zsh_completion}
             cp sftpgo.json output/
             cp -r templates output/

+ 6 - 6
.github/workflows/release.yml

@@ -5,7 +5,7 @@ on:
     tags: 'v*'
 
 env:
-  GO_VERSION: 1.16.3
+  GO_VERSION: 1.16.5
 
 jobs:
   prepare-sources-with-deps:
@@ -53,18 +53,18 @@ jobs:
 
       - name: Build for macOS x86_64
         if: startsWith(matrix.os, 'windows-') != true
-        run: go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo
+        run: go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo
 
       - name: Build for macOS arm64
         if: startsWith(matrix.os, 'macos-') == true
-        run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64
+        run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64
 
       - name: Build for Windows
         if: startsWith(matrix.os, 'windows-')
         run: |
           $GIT_COMMIT = (git describe --always --dirty) | Out-String
           $DATE_TIME = ([datetime]::Now.ToUniversalTime().toString("yyyy-MM-ddTHH:mm:ssZ")) | Out-String
-          go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/version.date=$DATE_TIME" -o sftpgo.exe
+          go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/version.date=$DATE_TIME" -o sftpgo.exe
 
       - name: Initialize data provider
         run: ./sftpgo initprovider
@@ -227,7 +227,7 @@ jobs:
       - name: Build on amd64
         if: ${{ matrix.arch == 'amd64' }}
         run: |
-          go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo
+          go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/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
@@ -269,7 +269,7 @@ jobs:
             tar -C /usr/local -xzf go.tar.gz
           run: |
             export PATH=$PATH:/usr/local/go/bin
-            go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo
+            go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/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

+ 1 - 1
Dockerfile

@@ -21,7 +21,7 @@ COPY . .
 
 RUN set -xe && \
     export COMMIT_SHA=${COMMIT_SHA:-$(git describe --always --dirty)} && \
-    go build $(if [ -n "${FEATURES}" ]; then echo "-tags ${FEATURES}"; fi) -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=${COMMIT_SHA} -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -v -o sftpgo
+    go build $(if [ -n "${FEATURES}" ]; then echo "-tags ${FEATURES}"; fi) -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=${COMMIT_SHA} -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -v -o sftpgo
 
 FROM debian:buster-slim
 

+ 1 - 1
Dockerfile.alpine

@@ -23,7 +23,7 @@ COPY . .
 
 RUN set -xe && \
     export COMMIT_SHA=${COMMIT_SHA:-$(git describe --always --dirty)} && \
-    go build $(if [ -n "${FEATURES}" ]; then echo "-tags ${FEATURES}"; fi) -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=${COMMIT_SHA} -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -v -o sftpgo
+    go build $(if [ -n "${FEATURES}" ]; then echo "-tags ${FEATURES}"; fi) -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=${COMMIT_SHA} -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -v -o sftpgo
 
 
 FROM alpine:3.13

+ 2 - 0
common/common.go

@@ -103,6 +103,8 @@ var (
 	ErrConnectionDenied     = errors.New("you are not allowed to connect")
 	ErrNoBinding            = errors.New("no binding configured")
 	ErrCrtRevoked           = errors.New("your certificate has been revoked")
+	ErrNoCredentials        = errors.New("no credential provided")
+	ErrInternalFailure      = errors.New("internal failure")
 	errNoTransfer           = errors.New("requested transfer not found")
 	errTransferMismatch     = errors.New("transfer mismatch")
 )

+ 17 - 17
common/protocol_test.go

@@ -211,7 +211,7 @@ func TestBaseConnection(t *testing.T) {
 		}
 		err = client.RemoveDirectory(linkName)
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Failure")
+			assert.Contains(t, err.Error(), "SSH_FX_FAILURE")
 		}
 		err = client.Remove(testFileName)
 		assert.NoError(t, err)
@@ -1735,35 +1735,35 @@ func TestVirtualFoldersLink(t *testing.T) {
 		assert.NoError(t, err)
 		err = client.Symlink(testFileName, path.Join(vdirPath1, testFileName+".link1"))
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 		err = client.Symlink(testFileName, path.Join(vdirPath1, testDir, testFileName+".link1"))
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 		err = client.Symlink(testFileName, path.Join(vdirPath2, testFileName+".link1"))
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 		err = client.Symlink(testFileName, path.Join(vdirPath2, testDir, testFileName+".link1"))
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 		err = client.Symlink(path.Join(vdirPath1, testFileName), testFileName+".link1")
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 		err = client.Symlink(path.Join(vdirPath2, testFileName), testFileName+".link1")
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 		err = client.Symlink(path.Join(vdirPath1, testFileName), path.Join(vdirPath2, testDir, testFileName+".link1"))
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 		err = client.Symlink(path.Join(vdirPath2, testFileName), path.Join(vdirPath1, testFileName+".link1"))
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 		err = client.Symlink("/", "/roolink")
 		assert.ErrorIs(t, err, os.ErrPermission)
@@ -1771,11 +1771,11 @@ func TestVirtualFoldersLink(t *testing.T) {
 		assert.ErrorIs(t, err, os.ErrPermission)
 		err = client.Symlink(testFileName, vdirPath1)
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 		err = client.Symlink(vdirPath1, testFileName+".link2")
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 	}
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
@@ -1828,7 +1828,7 @@ func TestDirs(t *testing.T) {
 		assert.ErrorIs(t, err, os.ErrPermission)
 		err = client.RemoveDirectory(path.Dir(vdirPath))
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 		err = client.Mkdir(vdirPath)
 		assert.ErrorIs(t, err, os.ErrPermission)
@@ -1836,13 +1836,13 @@ func TestDirs(t *testing.T) {
 		assert.NoError(t, err)
 		err = client.Rename("/adir", path.Dir(vdirPath))
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 		err = client.MkdirAll("/subdir/adir")
 		assert.NoError(t, err)
 		err = client.Rename("adir", "subdir/adir")
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 		err = writeSFTPFile("/subdir/afile.bin", 64, client)
 		assert.NoError(t, err)
@@ -1854,7 +1854,7 @@ func TestDirs(t *testing.T) {
 		assert.NoError(t, err)
 		err = client.Rename(path.Dir(vdirPath), "renamed_vdir")
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 	}
 
@@ -2513,7 +2513,7 @@ func TestNonLocalCrossRename(t *testing.T) {
 		// renaming a path to a virtual folder is not allowed
 		err = client.Rename("/vdir", "new_vdir")
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 	}
 
@@ -2608,7 +2608,7 @@ func TestNonLocalCrossRenameNonLocalBaseUser(t *testing.T) {
 		// renaming a path to a virtual folder is not allowed
 		err = client.Rename("/vdir", "new_vdir")
 		if assert.Error(t, err) {
-			assert.Contains(t, err.Error(), "Operation Unsupported")
+			assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED")
 		}
 	}
 

+ 4 - 2
dataprovider/cachedpassword.go

@@ -1,6 +1,8 @@
 package dataprovider
 
-import "sync"
+import (
+	"sync"
+)
 
 var cachedPasswords passwordsCache
 
@@ -37,7 +39,7 @@ func (c *passwordsCache) Remove(username string) {
 	delete(c.cache, username)
 }
 
-// returns if the user is found and if the password match
+// Check returns if the user is found and if the password match
 func (c *passwordsCache) Check(username, password string) (bool, bool) {
 	if username == "" || password == "" {
 		return false, false

+ 3 - 3
ftpd/server.go

@@ -332,7 +332,7 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext
 	if err != nil {
 		errClose := user.CloseFs()
 		logger.Warn(logSender, connectionID, "unable to check fs root: %v close fs error: %v", err, errClose)
-		return nil, err
+		return nil, common.ErrInternalFailure
 	}
 	connection := &Connection{
 		BaseConnection: common.NewBaseConnection(fmt.Sprintf("%v_%v", s.ID, cc.ID()), common.ProtocolFTP, remoteAddr, user),
@@ -342,14 +342,14 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext
 	if err != nil {
 		err = user.CloseFs()
 		logger.Warn(logSender, connectionID, "unable to swap connection, close fs error: %v", err)
-		return nil, errors.New("internal authentication error")
+		return nil, common.ErrInternalFailure
 	}
 	return connection, nil
 }
 
 func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error) {
 	metrics.AddLoginAttempt(loginMethod)
-	if err != nil {
+	if err != nil && err != common.ErrInternalFailure {
 		logger.ConnectionFailedLog(user.Username, ip, loginMethod,
 			common.ProtocolFTP, err.Error())
 		event := common.HostEventLoginFailed

+ 12 - 14
go.mod

@@ -8,27 +8,27 @@ require (
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/StackExchange/wmi v0.0.0-20210224194228-fe8f1750fd46 // indirect
 	github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8
-	github.com/aws/aws-sdk-go v1.38.51
+	github.com/aws/aws-sdk-go v1.38.55
 	github.com/cockroachdb/cockroach-go/v2 v2.1.1
 	github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect
-	github.com/eikenb/pipeat v0.0.0-20210528001815-f1fcb2512a3a
-	github.com/fclairamb/ftpserverlib v0.13.1
+	github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
+	github.com/fclairamb/ftpserverlib v0.13.2
 	github.com/frankban/quicktest v1.13.0 // indirect
 	github.com/go-chi/chi/v5 v5.0.3
 	github.com/go-chi/jwtauth/v5 v5.0.1
 	github.com/go-chi/render v1.0.1
 	github.com/go-ole/go-ole v1.2.5 // indirect
 	github.com/go-sql-driver/mysql v1.6.0
-	github.com/goccy/go-json v0.5.1 // indirect
+	github.com/goccy/go-json v0.6.1 // indirect
 	github.com/google/go-cmp v0.5.6 // indirect
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
 	github.com/grandcat/zeroconf v1.0.0
 	github.com/hashicorp/go-retryablehttp v0.7.0
 	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
-	github.com/klauspost/compress v1.12.3
+	github.com/klauspost/compress v1.13.0
 	github.com/klauspost/cpuid/v2 v2.0.6 // indirect
 	github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
-	github.com/lestrrat-go/jwx v1.2.0
+	github.com/lestrrat-go/jwx v1.2.1
 	github.com/lib/pq v1.10.2
 	github.com/magiconair/properties v1.8.5 // indirect
 	github.com/mattn/go-sqlite3 v1.14.7
@@ -37,14 +37,14 @@ require (
 	github.com/otiai10/copy v1.6.0
 	github.com/pelletier/go-toml v1.9.1 // indirect
 	github.com/pires/go-proxyproto v0.5.0
-	github.com/pkg/sftp v1.13.0
+	github.com/pkg/sftp v1.13.1-0.20210522170736-5b98d05076b8
 	github.com/prometheus/client_golang v1.10.0
-	github.com/prometheus/common v0.25.0 // indirect
+	github.com/prometheus/common v0.27.0 // indirect
 	github.com/rs/cors v1.7.1-0.20200626170627-8b4a00bd362b
 	github.com/rs/xid v1.3.0
 	github.com/rs/zerolog v1.22.0
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
-	github.com/shirou/gopsutil/v3 v3.21.4
+	github.com/shirou/gopsutil/v3 v3.21.5
 	github.com/spf13/afero v1.6.0
 	github.com/spf13/cast v1.3.1 // indirect
 	github.com/spf13/cobra v1.1.3
@@ -53,18 +53,16 @@ require (
 	github.com/stretchr/testify v1.7.0
 	github.com/studio-b12/gowebdav v0.0.0-20210427212133-86f8378cf140
 	github.com/yl2chen/cidranger v1.0.2
-	go.etcd.io/bbolt v1.3.5
+	go.etcd.io/bbolt v1.3.6
 	go.uber.org/automaxprocs v1.4.0
 	gocloud.dev v0.23.0
 	gocloud.dev/secrets/hashivault v0.23.0
 	golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
 	golang.org/x/net v0.0.0-20210525063256-abc453219eb5
-	golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea
+	golang.org/x/sys v0.0.0-20210603125802-9665404d3644
 	golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba
-	golang.org/x/tools v0.1.2 // indirect
 	google.golang.org/api v0.47.0
-	google.golang.org/genproto v0.0.0-20210524171403-669157292da3 // indirect
-	google.golang.org/grpc v1.38.0 // indirect
+	google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08 // indirect
 	gopkg.in/ini.v1 v1.62.0 // indirect
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect

+ 28 - 24
go.sum

@@ -127,8 +127,8 @@ github.com/aws/aws-sdk-go v1.25.37/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpi
 github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.30.27/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
 github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
-github.com/aws/aws-sdk-go v1.38.51 h1:aKQmbVbwOCuQSd8+fm/MR3bq0QOsu9Q7S+/QEND36oQ=
-github.com/aws/aws-sdk-go v1.38.51/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
+github.com/aws/aws-sdk-go v1.38.55 h1:1Wv5CE1Zy0hJ6MJUQ1ekFiCsNKBK5W69+towYQ1P4Vs=
+github.com/aws/aws-sdk-go v1.38.55/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
 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=
@@ -215,8 +215,8 @@ github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m
 github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
 github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
 github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
-github.com/eikenb/pipeat v0.0.0-20210528001815-f1fcb2512a3a h1:+btFAKG3kNCqm1DMKDGaWkolX/4aytcbvnfdgt6z+UI=
-github.com/eikenb/pipeat v0.0.0-20210528001815-f1fcb2512a3a/go.mod h1:+JPhBw5JdJrSF80r6xsSg1TYHjyAGxYs4X24VyUdMZU=
+github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b h1:h7A/b/M1yuYqQwZmRf+9BKuUJQgkgdvXYAwVl7L7WBo=
+github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84=
 github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -228,8 +228,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
 github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
-github.com/fclairamb/ftpserverlib v0.13.1 h1:tCQuM6d7Rt7hwwhzlteQBZLz6XQnLlPG1Gtn1AnW6x4=
-github.com/fclairamb/ftpserverlib v0.13.1/go.mod h1:T7GFWYWSREftxINf3ielPlaQ+aiE04/elEH0zLwkxBA=
+github.com/fclairamb/ftpserverlib v0.13.2 h1:kh1n2NzdIJALzF1+X83MuNFPEU5Qx/q+Az5tv98th1M=
+github.com/fclairamb/ftpserverlib v0.13.2/go.mod h1:T7GFWYWSREftxINf3ielPlaQ+aiE04/elEH0zLwkxBA=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
@@ -261,6 +261,7 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2
 github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
 github.com/go-kit/kit v0.10.0 h1:dXFJfIHVvUcpSgDOV+Ne6t7jXri8Tfv2uOLHUZ2XNuo=
 github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
+github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
 github.com/go-ldap/ldap/v3 v3.1.3/go.mod h1:3rbOH3jRS2u6jg2rJnKAMLE/xQyCKIveG2Sa/Cohzb8=
 github.com/go-ldap/ldap/v3 v3.1.10/go.mod h1:5Zun81jBTabRaI8lzN7E1JjyEl1g6zI6u9pd8luAK4Q=
 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
@@ -288,8 +289,8 @@ github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22
 github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
 github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
 github.com/goccy/go-json v0.4.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
-github.com/goccy/go-json v0.5.1 h1:R9UYTOUvo7eIY9aeDMZ4L6OVtHaSr1k2No9W6MKjXrA=
-github.com/goccy/go-json v0.5.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/goccy/go-json v0.6.1 h1:O7xC9WR7B09imThbRIEMIWK4MVcxOsLzWtGe16cv5SU=
+github.com/goccy/go-json v0.6.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
@@ -544,8 +545,8 @@ github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQL
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
 github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
-github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU=
-github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
+github.com/klauspost/compress v1.13.0 h1:2T7tUoQrQT+fQWdaY5rjWztFGAFwbGD04iPJg90ZiOs=
+github.com/klauspost/compress v1.13.0/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
 github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.0.6 h1:dQ5ueTiftKxp0gyjKSx5+8BtPWkyQbd95m8Gys/RarI=
 github.com/klauspost/cpuid/v2 v2.0.6/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -577,8 +578,8 @@ github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++
 github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A=
 github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
 github.com/lestrrat-go/jwx v1.1.6/go.mod h1:c+R8G7qsaFNmTzYjU98A+sMh8Bo/MJqO9GnpqR+X024=
-github.com/lestrrat-go/jwx v1.2.0 h1:n08WEu8cJy3uzuQ39KWAOIhM4XfeozgaEGA8mTiioZ8=
-github.com/lestrrat-go/jwx v1.2.0/go.mod h1:Tg2uP7bpxEHUDtuWjap/PxroJ4okxGzkQznXiG+a5Dc=
+github.com/lestrrat-go/jwx v1.2.1 h1:WJ/3tiPUz1wV24KiwMEanbENwHnYub9UqzCbQ82mv9c=
+github.com/lestrrat-go/jwx v1.2.1/go.mod h1:Tg2uP7bpxEHUDtuWjap/PxroJ4okxGzkQznXiG+a5Dc=
 github.com/lestrrat-go/option v0.0.0-20210103042652-6f1ecfceda35/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
 github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
 github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
@@ -716,8 +717,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
 github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
-github.com/pkg/sftp v1.13.0 h1:Riw6pgOKK41foc1I1Uu03CjvbLZDXeGpInycM4shXoI=
-github.com/pkg/sftp v1.13.0/go.mod h1:41g+FIPlQUTDCveupEmEA65IoiQFrtgCeDopC4ajGIM=
+github.com/pkg/sftp v1.13.1-0.20210522170736-5b98d05076b8 h1:vvdLIAEGhdfZaetTYpjq9cy4vob6dYLN6lxw1GVXd+g=
+github.com/pkg/sftp v1.13.1-0.20210522170736-5b98d05076b8/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
 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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
@@ -747,8 +748,8 @@ github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt2
 github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
 github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
 github.com/prometheus/common v0.18.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s=
-github.com/prometheus/common v0.25.0 h1:IjJYZJCI8HZYtqA3xYwGyDzSCy1r4CA2GRh+4vdOmtE=
-github.com/prometheus/common v0.25.0/go.mod h1:H6QK/N6XVT42whUeIdI3dp36w49c+/iMDk7UAI2qm7Q=
+github.com/prometheus/common v0.27.0 h1:kJb5BtkTmonXrV2nfyRRlChGpgqhPCdj2ooGivZ8txo=
+github.com/prometheus/common v0.27.0/go.mod h1:LdLj/WiR+LL0ThCPrtSZbijrsxInIhizDTiPlJhPPq4=
 github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
@@ -784,8 +785,8 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
-github.com/shirou/gopsutil/v3 v3.21.4 h1:XB/+p+kVnyYLuPHCfa99lxz2aJyvVhnyd+FxZqH/k7M=
-github.com/shirou/gopsutil/v3 v3.21.4/go.mod h1:ghfMypLDrFSWN2c9cDYFLHyynQ+QUht0cv/18ZqVczw=
+github.com/shirou/gopsutil/v3 v3.21.5 h1:YUBf0w/KPLk7w1803AYBnH7BmA+1Z/Q5MEZxpREUaB4=
+github.com/shirou/gopsutil/v3 v3.21.5/go.mod h1:ghfMypLDrFSWN2c9cDYFLHyynQ+QUht0cv/18ZqVczw=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
@@ -861,8 +862,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
 github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
 go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
 go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
-go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
-go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
+go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
+go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
 go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
 go.opencensus.io v0.15.0/go.mod h1:UffZAU+4sDEINUGP/B7UfBBkq4fqLu9zXAX7ke6CHW0=
 go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
@@ -1010,6 +1011,7 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1025,12 +1027,14 @@ golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210420205809-ac73e9fd8988/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20210503173754-0981d6026fa6/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-20210525143221-35b2ab0089ea h1:+WiDlPBBaO+h9vPNZi8uJ3k4BkKQB7Iow3aqwHVA5hI=
-golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210603125802-9665404d3644 h1:CA1DEQ4NdKphKeL70tvsWNdT5oFh1lOjihRcEDROi0I=
+golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1208,8 +1212,8 @@ google.golang.org/genproto v0.0.0-20210423144448-3a41ef94ed2b/go.mod h1:P3QM42oQ
 google.golang.org/genproto v0.0.0-20210429181445-86c259c2b4ab/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
 google.golang.org/genproto v0.0.0-20210506142907-4a47615972c2/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
 google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
-google.golang.org/genproto v0.0.0-20210524171403-669157292da3 h1:xFyh6GBb+NO1L0xqb978I3sBPQpk6FrKO0jJGRvdj/0=
-google.golang.org/genproto v0.0.0-20210524171403-669157292da3/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
+google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08 h1:pc16UedxnxXXtGxHCSUhafAoVHQZ0yXl8ZelMH4EETc=
+google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
 google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=

+ 1 - 6
httpd/api_admin.go

@@ -11,11 +11,6 @@ import (
 	"github.com/drakkan/sftpgo/dataprovider"
 )
 
-type adminPwd struct {
-	CurrentPassword string `json:"current_password"`
-	NewPassword     string `json:"new_password"`
-}
-
 func getAdmins(w http.ResponseWriter, r *http.Request) {
 	limit, offset, order, err := getSearchFilters(w, r)
 	if err != nil {
@@ -129,7 +124,7 @@ func deleteAdmin(w http.ResponseWriter, r *http.Request) {
 func changeAdminPassword(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 
-	var pwd adminPwd
+	var pwd pwdChange
 	err := render.DecodeJSON(r.Body, &pwd)
 	if err != nil {
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)

+ 238 - 0
httpd/api_http_user.go

@@ -0,0 +1,238 @@
+package httpd
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/go-chi/render"
+	"github.com/rs/xid"
+
+	"github.com/drakkan/sftpgo/common"
+	"github.com/drakkan/sftpgo/dataprovider"
+	"github.com/drakkan/sftpgo/utils"
+)
+
+func readUserFolder(w http.ResponseWriter, r *http.Request) {
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	user, err := dataprovider.UserExists(claims.Username)
+	if err != nil {
+		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
+		return
+	}
+	connID := xid.New().String()
+	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID)
+	if err := checkHTTPClientUser(&user, r, connectionID); err != nil {
+		sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+	connection := &Connection{
+		BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, r.RemoteAddr, user),
+		request:        r,
+	}
+	common.Connections.Add(connection)
+	defer common.Connections.Remove(connection.GetID())
+
+	name := utils.CleanPath(r.URL.Query().Get("path"))
+	contents, err := connection.ReadDir(name)
+	if err != nil {
+		sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err))
+		return
+	}
+	results := make([]map[string]interface{}, 0, len(contents))
+	for _, info := range contents {
+		res := make(map[string]interface{})
+		res["name"] = info.Name()
+		if info.Mode().IsRegular() {
+			res["size"] = info.Size()
+		}
+		res["mode"] = info.Mode()
+		res["last_modified"] = info.ModTime().UTC().Format(time.RFC3339)
+		results = append(results, res)
+	}
+
+	render.JSON(w, r, results)
+}
+
+func getUserFile(w http.ResponseWriter, r *http.Request) {
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	user, err := dataprovider.UserExists(claims.Username)
+	if err != nil {
+		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
+		return
+	}
+	connID := xid.New().String()
+	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID)
+	if err := checkHTTPClientUser(&user, r, connectionID); err != nil {
+		sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+	connection := &Connection{
+		BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, r.RemoteAddr, user),
+		request:        r,
+	}
+	common.Connections.Add(connection)
+	defer common.Connections.Remove(connection.GetID())
+
+	name := utils.CleanPath(r.URL.Query().Get("path"))
+	if name == "/" {
+		sendAPIResponse(w, r, nil, "Please set the path to a valid file", http.StatusBadRequest)
+		return
+	}
+	info, err := connection.Stat(name, 0)
+	if err != nil {
+		sendAPIResponse(w, r, err, "Unable to stat the requested file", getMappedStatusCode(err))
+		return
+	}
+	if info.IsDir() {
+		sendAPIResponse(w, r, nil, fmt.Sprintf("Please set the path to a valid file, %#v is a directory", name), http.StatusBadRequest)
+		return
+	}
+
+	if status, err := downloadFile(w, r, connection, name, info); err != nil {
+		resp := apiResponse{
+			Error:   err.Error(),
+			Message: http.StatusText(status),
+		}
+		ctx := r.Context()
+		if status != 0 {
+			ctx = context.WithValue(ctx, render.StatusCtxKey, status)
+		}
+		render.JSON(w, r.WithContext(ctx), resp)
+	}
+}
+
+func getUserFilesAsZipStream(w http.ResponseWriter, r *http.Request) {
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	user, err := dataprovider.UserExists(claims.Username)
+	if err != nil {
+		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
+		return
+	}
+	connID := xid.New().String()
+	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID)
+	if err := checkHTTPClientUser(&user, r, connectionID); err != nil {
+		sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+	connection := &Connection{
+		BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, r.RemoteAddr, user),
+		request:        r,
+	}
+	common.Connections.Add(connection)
+	defer common.Connections.Remove(connection.GetID())
+
+	var filesList []string
+	err = render.DecodeJSON(r.Body, &filesList)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+
+	baseDir := "/"
+	for idx := range filesList {
+		filesList[idx] = utils.CleanPath(filesList[idx])
+	}
+
+	w.Header().Set("Content-Disposition", "attachment; filename=\"sftpgo-download.zip\"")
+	renderCompressedFiles(w, connection, baseDir, filesList)
+}
+
+func getUserPublicKeys(w http.ResponseWriter, r *http.Request) {
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	user, err := dataprovider.UserExists(claims.Username)
+	if err != nil {
+		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
+		return
+	}
+	render.JSON(w, r, user.PublicKeys)
+}
+
+func setUserPublicKeys(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	user, err := dataprovider.UserExists(claims.Username)
+	if err != nil {
+		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
+		return
+	}
+
+	var publicKeys []string
+	err = render.DecodeJSON(r.Body, &publicKeys)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+
+	user.PublicKeys = publicKeys
+	err = dataprovider.UpdateUser(&user)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	sendAPIResponse(w, r, err, "Public keys updated", http.StatusOK)
+}
+
+func changeUserPassword(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+
+	var pwd pwdChange
+	err := render.DecodeJSON(r.Body, &pwd)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+	err = doChangeUserPassword(r, pwd.CurrentPassword, pwd.NewPassword, pwd.NewPassword)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	sendAPIResponse(w, r, err, "Password updated", http.StatusOK)
+}
+
+func doChangeUserPassword(r *http.Request, currentPassword, newPassword, confirmNewPassword string) error {
+	if currentPassword == "" || newPassword == "" || confirmNewPassword == "" {
+		return dataprovider.NewValidationError("please provide the current password and the new one two times")
+	}
+	if newPassword != confirmNewPassword {
+		return dataprovider.NewValidationError("the two password fields do not match")
+	}
+	if currentPassword == newPassword {
+		return dataprovider.NewValidationError("the new password must be different from the current one")
+	}
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		return errors.New("invalid token claims")
+	}
+	user, err := dataprovider.CheckUserAndPass(claims.Username, currentPassword, utils.GetIPFromRemoteAddress(r.RemoteAddr),
+		common.ProtocolHTTP)
+	if err != nil {
+		return dataprovider.NewValidationError("current password does not match")
+	}
+	user.Password = newPassword
+
+	return dataprovider.UpdateUser(&user)
+}

+ 232 - 0
httpd/api_utils.go

@@ -3,12 +3,15 @@ package httpd
 import (
 	"context"
 	"errors"
+	"fmt"
 	"io"
+	"mime"
 	"net/http"
 	"os"
 	"path"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/go-chi/render"
 	"github.com/klauspost/compress/zip"
@@ -16,8 +19,15 @@ import (
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/dataprovider"
 	"github.com/drakkan/sftpgo/logger"
+	"github.com/drakkan/sftpgo/metrics"
+	"github.com/drakkan/sftpgo/utils"
 )
 
+type pwdChange struct {
+	CurrentPassword string `json:"current_password"`
+	NewPassword     string `json:"new_password"`
+}
+
 func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message string, code int) {
 	var errorString string
 	if err != nil {
@@ -47,6 +57,19 @@ func getRespStatus(err error) int {
 	return http.StatusInternalServerError
 }
 
+func getMappedStatusCode(err error) int {
+	var statusCode int
+	switch err {
+	case os.ErrPermission:
+		statusCode = http.StatusForbidden
+	case os.ErrNotExist:
+		statusCode = http.StatusNotFound
+	default:
+		statusCode = http.StatusInternalServerError
+	}
+	return statusCode
+}
+
 func handleCloseConnection(w http.ResponseWriter, r *http.Request) {
 	connectionID := getURLParam(r, "connectionID")
 	if connectionID == "" {
@@ -166,3 +189,212 @@ func getZipEntryName(entryPath, baseDir string) string {
 	entryPath = strings.TrimPrefix(entryPath, baseDir)
 	return strings.TrimPrefix(entryPath, "/")
 }
+
+func downloadFile(w http.ResponseWriter, r *http.Request, connection *Connection, name string, info os.FileInfo) (int, error) {
+	var err error
+	rangeHeader := r.Header.Get("Range")
+	if rangeHeader != "" && checkIfRange(r, info.ModTime()) == condFalse {
+		rangeHeader = ""
+	}
+	offset := int64(0)
+	size := info.Size()
+	responseStatus := http.StatusOK
+	if strings.HasPrefix(rangeHeader, "bytes=") {
+		if strings.Contains(rangeHeader, ",") {
+			return http.StatusRequestedRangeNotSatisfiable, fmt.Errorf("unsupported range %#v", rangeHeader)
+		}
+		offset, size, err = parseRangeRequest(rangeHeader[6:], size)
+		if err != nil {
+			return http.StatusRequestedRangeNotSatisfiable, err
+		}
+		responseStatus = http.StatusPartialContent
+	}
+	reader, err := connection.getFileReader(name, offset, r.Method)
+	if err != nil {
+		return getMappedStatusCode(err), fmt.Errorf("unable to read file %#v: %v", name, err)
+	}
+	defer reader.Close()
+
+	w.Header().Set("Last-Modified", info.ModTime().UTC().Format(http.TimeFormat))
+	if checkPreconditions(w, r, info.ModTime()) {
+		return 0, fmt.Errorf("%v", http.StatusText(http.StatusPreconditionFailed))
+	}
+	ctype := mime.TypeByExtension(path.Ext(name))
+	if ctype == "" {
+		ctype = "application/octet-stream"
+	}
+	if responseStatus == http.StatusPartialContent {
+		w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, info.Size()))
+	}
+	w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
+	w.Header().Set("Content-Type", ctype)
+	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%#v", path.Base(name)))
+	w.Header().Set("Accept-Ranges", "bytes")
+	w.WriteHeader(responseStatus)
+	if r.Method != http.MethodHead {
+		io.CopyN(w, reader, size) //nolint:errcheck
+	}
+	return http.StatusOK, nil
+}
+
+func checkPreconditions(w http.ResponseWriter, r *http.Request, modtime time.Time) bool {
+	if checkIfUnmodifiedSince(r, modtime) == condFalse {
+		w.WriteHeader(http.StatusPreconditionFailed)
+		return true
+	}
+	if checkIfModifiedSince(r, modtime) == condFalse {
+		w.WriteHeader(http.StatusNotModified)
+		return true
+	}
+	return false
+}
+
+func checkIfUnmodifiedSince(r *http.Request, modtime time.Time) condResult {
+	ius := r.Header.Get("If-Unmodified-Since")
+	if ius == "" || isZeroTime(modtime) {
+		return condNone
+	}
+	t, err := http.ParseTime(ius)
+	if err != nil {
+		return condNone
+	}
+
+	// The Last-Modified header truncates sub-second precision so
+	// the modtime needs to be truncated too.
+	modtime = modtime.Truncate(time.Second)
+	if modtime.Before(t) || modtime.Equal(t) {
+		return condTrue
+	}
+	return condFalse
+}
+
+func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult {
+	if r.Method != http.MethodGet && r.Method != http.MethodHead {
+		return condNone
+	}
+	ims := r.Header.Get("If-Modified-Since")
+	if ims == "" || isZeroTime(modtime) {
+		return condNone
+	}
+	t, err := http.ParseTime(ims)
+	if err != nil {
+		return condNone
+	}
+	// The Last-Modified header truncates sub-second precision so
+	// the modtime needs to be truncated too.
+	modtime = modtime.Truncate(time.Second)
+	if modtime.Before(t) || modtime.Equal(t) {
+		return condFalse
+	}
+	return condTrue
+}
+
+func checkIfRange(r *http.Request, modtime time.Time) condResult {
+	if r.Method != http.MethodGet && r.Method != http.MethodHead {
+		return condNone
+	}
+	ir := r.Header.Get("If-Range")
+	if ir == "" {
+		return condNone
+	}
+	if modtime.IsZero() {
+		return condFalse
+	}
+	t, err := http.ParseTime(ir)
+	if err != nil {
+		return condFalse
+	}
+	if modtime.Add(60 * time.Second).Before(t) {
+		return condTrue
+	}
+	return condFalse
+}
+
+func parseRangeRequest(bytesRange string, size int64) (int64, int64, error) {
+	var start, end int64
+	var err error
+
+	values := strings.Split(bytesRange, "-")
+	if values[0] == "" {
+		start = -1
+	} else {
+		start, err = strconv.ParseInt(values[0], 10, 64)
+		if err != nil {
+			return start, size, err
+		}
+	}
+	if len(values) >= 2 {
+		if values[1] != "" {
+			end, err = strconv.ParseInt(values[1], 10, 64)
+			if err != nil {
+				return start, size, err
+			}
+			if end >= size {
+				end = size - 1
+			}
+		}
+	}
+	if start == -1 && end == 0 {
+		return 0, 0, fmt.Errorf("unsupported range %#v", bytesRange)
+	}
+
+	if end > 0 {
+		if start == -1 {
+			// we have something like -500
+			start = size - end
+			size = end
+			// start cannit be < 0 here, we did end = size -1 above
+		} else {
+			// we have something like 500-600
+			size = end - start + 1
+			if size < 0 {
+				return 0, 0, fmt.Errorf("unacceptable range %#v", bytesRange)
+			}
+		}
+		return start, size, nil
+	}
+	// we have something like 500-
+	size -= start
+	if size < 0 {
+		return 0, 0, fmt.Errorf("unacceptable range %#v", bytesRange)
+	}
+	return start, size, err
+}
+
+func updateLoginMetrics(user *dataprovider.User, ip string, err error) {
+	metrics.AddLoginAttempt(dataprovider.LoginMethodPassword)
+	if err != nil && err != common.ErrInternalFailure {
+		logger.ConnectionFailedLog(user.Username, ip, dataprovider.LoginMethodPassword, common.ProtocolHTTP, err.Error())
+		event := common.HostEventLoginFailed
+		if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
+			event = common.HostEventUserNotFound
+		}
+		common.AddDefenderEvent(ip, event)
+	}
+	metrics.AddLoginResult(dataprovider.LoginMethodPassword, err)
+	dataprovider.ExecutePostLoginHook(user, dataprovider.LoginMethodPassword, ip, common.ProtocolHTTP, err)
+}
+
+func checkHTTPClientUser(user *dataprovider.User, r *http.Request, connectionID string) error {
+	if utils.IsStringInSlice(common.ProtocolHTTP, user.Filters.DeniedProtocols) {
+		logger.Debug(logSender, connectionID, "cannot login user %#v, protocol HTTP is not allowed", user.Username)
+		return fmt.Errorf("protocol HTTP is not allowed for user %#v", user.Username)
+	}
+	if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) {
+		logger.Debug(logSender, connectionID, "cannot login user %#v, password login method is not allowed", user.Username)
+		return fmt.Errorf("login method password is not allowed for user %#v", user.Username)
+	}
+	if user.MaxSessions > 0 {
+		activeSessions := common.Connections.GetActiveSessions(user.Username)
+		if activeSessions >= user.MaxSessions {
+			logger.Debug(logSender, connectionID, "authentication refused for user: %#v, too many open sessions: %v/%v", user.Username,
+				activeSessions, user.MaxSessions)
+			return fmt.Errorf("too many open sessions: %v", activeSessions)
+		}
+	}
+	if !user.IsLoginFromAddrAllowed(r.RemoteAddr) {
+		logger.Debug(logSender, connectionID, "cannot login user %#v, remote address is not allowed: %v", user.Username, r.RemoteAddr)
+		return fmt.Errorf("login for user %#v is not allowed from this address: %v", user.Username, r.RemoteAddr)
+	}
+	return nil
+}

+ 1 - 0
httpd/auth_utils.go

@@ -21,6 +21,7 @@ const (
 	tokenAudienceWebAdmin  tokenAudience = "WebAdmin"
 	tokenAudienceWebClient tokenAudience = "WebClient"
 	tokenAudienceAPI       tokenAudience = "API"
+	tokenAudienceAPIUser   tokenAudience = "APIUser"
 	tokenAudienceCSRF      tokenAudience = "CSRF"
 )
 

+ 12 - 4
httpd/httpd.go

@@ -32,6 +32,8 @@ const (
 	logSender                       = "httpd"
 	tokenPath                       = "/api/v2/token"
 	logoutPath                      = "/api/v2/logout"
+	userTokenPath                   = "/api/v2/user/token"
+	userLogoutPath                  = "/api/v2/user/logout"
 	activeConnectionsPath           = "/api/v2/connections"
 	quotaScanPath                   = "/api/v2/quota-scans"
 	quotaScanVFolderPath            = "/api/v2/folder-quota-scans"
@@ -47,7 +49,13 @@ const (
 	defenderUnban                   = "/api/v2/defender/unban"
 	defenderScore                   = "/api/v2/defender/score"
 	adminPath                       = "/api/v2/admins"
-	adminPwdPath                    = "/api/v2/changepwd/admin"
+	adminPwdPath                    = "/api/v2/admin/changepwd"
+	adminPwdCompatPath              = "/api/v2/changepwd/admin"
+	userPwdPath                     = "/api/v2/user/changepwd"
+	userPublicKeysPath              = "/api/v2/user/publickeys"
+	userReadFolderPath              = "/api/v2/user/folder"
+	userGetFilePath                 = "/api/v2/user/file"
+	userStreamZipPath               = "/api/v2/user/streamzip"
 	healthzPath                     = "/healthz"
 	webRootPathDefault              = "/"
 	webBasePathDefault              = "/web"
@@ -75,7 +83,7 @@ const (
 	webClientLoginPathDefault       = "/web/client/login"
 	webClientFilesPathDefault       = "/web/client/files"
 	webClientDirContentsPathDefault = "/web/client/listdir"
-	webClientDownloadPathDefault    = "/web/client/download"
+	webClientDownloadZipPathDefault = "/web/client/downloadzip"
 	webClientCredentialsPathDefault = "/web/client/credentials"
 	webChangeClientPwdPathDefault   = "/web/client/changepwd"
 	webChangeClientKeysPathDefault  = "/web/client/managekeys"
@@ -121,7 +129,7 @@ var (
 	webClientLoginPath       string
 	webClientFilesPath       string
 	webClientDirContentsPath string
-	webClientDownloadPath    string
+	webClientDownloadZipPath string
 	webClientCredentialsPath string
 	webChangeClientPwdPath   string
 	webChangeClientKeysPath  string
@@ -417,7 +425,7 @@ func updateWebClientURLs(baseURL string) {
 	webClientLoginPath = path.Join(baseURL, webClientLoginPathDefault)
 	webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault)
 	webClientDirContentsPath = path.Join(baseURL, webClientDirContentsPathDefault)
-	webClientDownloadPath = path.Join(baseURL, webClientDownloadPathDefault)
+	webClientDownloadZipPath = path.Join(baseURL, webClientDownloadZipPathDefault)
 	webClientCredentialsPath = path.Join(baseURL, webClientCredentialsPathDefault)
 	webChangeClientPwdPath = path.Join(baseURL, webChangeClientPwdPathDefault)
 	webChangeClientKeysPath = path.Join(baseURL, webChangeClientKeysPathDefault)

+ 550 - 25
httpd/httpd_test.go

@@ -56,9 +56,11 @@ const (
 	altAdminPassword          = "password1"
 	csrfFormToken             = "_form_token"
 	tokenPath                 = "/api/v2/token"
+	userTokenPath             = "/api/v2/user/token"
+	userLogoutPath            = "/api/v2/user/logout"
 	userPath                  = "/api/v2/users"
 	adminPath                 = "/api/v2/admins"
-	adminPwdPath              = "/api/v2/changepwd/admin"
+	adminPwdPath              = "/api/v2/admin/changepwd"
 	folderPath                = "/api/v2/folders"
 	activeConnectionsPath     = "/api/v2/connections"
 	serverStatusPath          = "/api/v2/status"
@@ -69,6 +71,11 @@ const (
 	defenderUnban             = "/api/v2/defender/unban"
 	versionPath               = "/api/v2/version"
 	logoutPath                = "/api/v2/logout"
+	userPwdPath               = "/api/v2/user/changepwd"
+	userPublicKeysPath        = "/api/v2/user/publickeys"
+	userReadFolderPath        = "/api/v2/user/folder"
+	userGetFilePath           = "/api/v2/user/file"
+	userStreamZipPath         = "/api/v2/user/streamzip"
 	healthzPath               = "/healthz"
 	webBasePath               = "/web"
 	webBasePathAdmin          = "/web/admin"
@@ -92,7 +99,7 @@ const (
 	webClientLoginPath        = "/web/client/login"
 	webClientFilesPath        = "/web/client/files"
 	webClientDirContentsPath  = "/web/client/listdir"
-	webClientDownloadPath     = "/web/client/download"
+	webClientDownloadZipPath  = "/web/client/downloadzip"
 	webClientCredentialsPath  = "/web/client/credentials"
 	webChangeClientPwdPath    = "/web/client/changepwd"
 	webChangeClientKeysPath   = "/web/client/managekeys"
@@ -372,6 +379,182 @@ func TestBasicUserHandling(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestHTTPUserAuthentication(t *testing.T) {
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	resp, err := httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+	responseHolder := make(map[string]interface{})
+	err = render.DecodeJSON(resp.Body, &responseHolder)
+	assert.NoError(t, err)
+	userToken := responseHolder["access_token"].(string)
+	assert.NotEmpty(t, userToken)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+	// login with wrong credentials
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, "")
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil)
+	assert.NoError(t, err)
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, "wrong pwd")
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+	respBody, err := io.ReadAll(resp.Body)
+	assert.NoError(t, err)
+	assert.Contains(t, string(respBody), "invalid credentials")
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth("wrong username", defaultPassword)
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+	respBody, err = io.ReadAll(resp.Body)
+	assert.NoError(t, err)
+	assert.Contains(t, string(respBody), "invalid credentials")
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, tokenPath), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultTokenAuthUser, defaultTokenAuthPass)
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+	responseHolder = make(map[string]interface{})
+	err = render.DecodeJSON(resp.Body, &responseHolder)
+	assert.NoError(t, err)
+	adminToken := responseHolder["access_token"].(string)
+	assert.NotEmpty(t, adminToken)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, versionPath), nil)
+	assert.NoError(t, err)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", adminToken))
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+	// using the user token should not work
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, versionPath), nil)
+	assert.NoError(t, err)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", userToken))
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userPublicKeysPath), nil)
+	assert.NoError(t, err)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", userToken))
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+	// using the admin token should not work
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userPublicKeysPath), nil)
+	assert.NoError(t, err)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", adminToken))
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userLogoutPath), nil)
+	assert.NoError(t, err)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", adminToken))
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userLogoutPath), nil)
+	assert.NoError(t, err)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", userToken))
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userPublicKeysPath), nil)
+	assert.NoError(t, err)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", userToken))
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestHTTPStreamZipError(t *testing.T) {
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+
+	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	resp, err := httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+	responseHolder := make(map[string]interface{})
+	err = render.DecodeJSON(resp.Body, &responseHolder)
+	assert.NoError(t, err)
+	userToken := responseHolder["access_token"].(string)
+	assert.NotEmpty(t, userToken)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+
+	filesList := []string{"missing"}
+	asJSON, err := json.Marshal(filesList)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, fmt.Sprintf("%v%v", httpBaseURL, userStreamZipPath), bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", userToken))
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	if !assert.Error(t, err) { // the connection will be closed
+		err = resp.Body.Close()
+		assert.NoError(t, err)
+	}
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestBasicAdminHandling(t *testing.T) {
 	// we have one admin by default
 	admins, _, err := httpdtest.GetAdmins(0, 0, http.StatusOK)
@@ -1044,16 +1227,6 @@ func TestAddUserInvalidVirtualFolders(t *testing.T) {
 	// folder name is mandatory
 	_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
 	assert.NoError(t, err)
-	u.VirtualFolders = nil
-	u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
-		BaseVirtualFolder: vfs.BaseVirtualFolder{
-			Name:       "aa=a", // char not allowed
-			MappedPath: filepath.Join(os.TempDir(), "mapped_dir"),
-		},
-		VirtualPath: "/vdir1",
-	})
-	_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
-	assert.NoError(t, err)
 }
 
 func TestUserPublicKey(t *testing.T) {
@@ -3496,6 +3669,50 @@ func TestChangeAdminPwdInvalidJsonMock(t *testing.T) {
 	checkResponseCode(t, http.StatusBadRequest, rr)
 }
 
+func TestWebAPIChangeUserPwdMock(t *testing.T) {
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+	token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	// invalid json
+	req, err := http.NewRequest(http.MethodPut, userPwdPath, bytes.NewBuffer([]byte("{")))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	pwd := make(map[string]string)
+	pwd["current_password"] = defaultPassword
+	pwd["new_password"] = defaultPassword
+	asJSON, err := json.Marshal(pwd)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPut, userPwdPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "the new password must be different from the current one")
+
+	pwd["new_password"] = altAdminPassword
+	asJSON, err = json.Marshal(pwd)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPut, userPwdPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	_, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.Error(t, err)
+	token, err = getJWTAPIUserTokenFromTestServer(defaultUsername, altAdminPassword)
+	assert.NoError(t, err)
+	assert.NotEmpty(t, token)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestLoginInvalidPasswordMock(t *testing.T) {
 	_, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass+"1")
 	assert.Error(t, err)
@@ -4508,6 +4725,50 @@ func TestTokenAudience(t *testing.T) {
 	assert.Equal(t, webLoginPath, rr.Header().Get("Location"))
 }
 
+func TestWebAPILoginMock(t *testing.T) {
+	_, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.Error(t, err)
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+	_, err = getJWTAPIUserTokenFromTestServer(defaultUsername+"1", defaultPassword)
+	assert.Error(t, err)
+	_, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword+"1")
+	assert.Error(t, err)
+	apiToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	// a web token is not valid for API usage
+	req, err := http.NewRequest(http.MethodGet, userReadFolderPath, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webToken)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusUnauthorized, rr)
+	assert.Contains(t, rr.Body.String(), "Your token audience is not valid")
+
+	req, err = http.NewRequest(http.MethodGet, userReadFolderPath+"/?path=%2F", nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, apiToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	// API token is not valid for web usage
+	req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil)
+	setJWTCookieForReq(req, apiToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusFound, rr)
+	assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
+
+	req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestWebClientLoginMock(t *testing.T) {
 	_, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.Error(t, err)
@@ -4556,6 +4817,8 @@ func TestWebClientLoginMock(t *testing.T) {
 	// get a new token and use it after removing the user
 	webToken, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.NoError(t, err)
+	apiUserToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
 	err = os.RemoveAll(user.GetHomeDir())
@@ -4568,19 +4831,49 @@ func TestWebClientLoginMock(t *testing.T) {
 	req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
-	checkResponseCode(t, http.StatusInternalServerError, rr)
-	assert.Contains(t, rr.Body.String(), "unable to retrieve your user")
+	checkResponseCode(t, http.StatusNotFound, rr)
+	assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
 
 	req, _ = http.NewRequest(http.MethodGet, webClientDirContentsPath, nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
-	checkResponseCode(t, http.StatusInternalServerError, rr)
-	assert.Contains(t, rr.Body.String(), "unable to retrieve your user")
+	checkResponseCode(t, http.StatusNotFound, rr)
+	assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
 
-	req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath, nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientDownloadZipPath, nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
-	checkResponseCode(t, http.StatusInternalServerError, rr)
+	checkResponseCode(t, http.StatusNotFound, rr)
+	assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
+
+	req, _ = http.NewRequest(http.MethodGet, userReadFolderPath, nil)
+	setBearerForReq(req, apiUserToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+	assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
+
+	req, _ = http.NewRequest(http.MethodGet, userGetFilePath, nil)
+	setBearerForReq(req, apiUserToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+	assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
+
+	req, _ = http.NewRequest(http.MethodPost, userStreamZipPath, bytes.NewBuffer([]byte(`{}`)))
+	setBearerForReq(req, apiUserToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+	assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
+
+	req, _ = http.NewRequest(http.MethodGet, userPublicKeysPath, nil)
+	setBearerForReq(req, apiUserToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+	assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
+
+	req, _ = http.NewRequest(http.MethodPut, userPublicKeysPath, bytes.NewBuffer([]byte(`{}`)))
+	setBearerForReq(req, apiUserToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
 	assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
 
 	csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
@@ -4671,6 +4964,7 @@ func TestDefender(t *testing.T) {
 	assert.NoError(t, err)
 	webToken, err := getJWTWebClientTokenFromTestServerWithAddr(defaultUsername, defaultPassword, remoteAddr)
 	assert.NoError(t, err)
+
 	req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil)
 	req.RemoteAddr = remoteAddr
 	req.RequestURI = webClientFilesPath
@@ -4733,12 +5027,18 @@ func TestPostConnectHook(t *testing.T) {
 	_, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.NoError(t, err)
 
+	_, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+
 	err = os.WriteFile(postConnectPath, getExitCodeScriptContent(1), os.ModePerm)
 	assert.NoError(t, err)
 
 	_, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.Error(t, err)
 
+	_, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.Error(t, err)
+
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
 	err = os.RemoveAll(user.GetHomeDir())
@@ -4754,6 +5054,8 @@ func TestMaxSessions(t *testing.T) {
 	assert.NoError(t, err)
 	_, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.NoError(t, err)
+	_, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
 	// now add a fake connection
 	fs := vfs.NewOsFs("id", os.TempDir(), "")
 	connection := &httpd.Connection{
@@ -4762,6 +5064,8 @@ func TestMaxSessions(t *testing.T) {
 	common.Connections.Add(connection)
 	_, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.Error(t, err)
+	_, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.Error(t, err)
 	common.Connections.Remove(connection.GetID())
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
@@ -4790,6 +5094,9 @@ func TestLoginInvalidFs(t *testing.T) {
 	_, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.Error(t, err)
 
+	_, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.Error(t, err)
+
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
 	err = os.RemoveAll(user.GetHomeDir())
@@ -4857,6 +5164,75 @@ func TestWebClientChangePwd(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestWebAPIPublicKeys(t *testing.T) {
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+	apiToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+
+	req, err := http.NewRequest(http.MethodGet, userPublicKeysPath, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, apiToken)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	var keys []string
+	err = json.Unmarshal(rr.Body.Bytes(), &keys)
+	assert.NoError(t, err)
+	assert.Len(t, keys, 0)
+
+	keys = []string{testPubKey, testPubKey1}
+	asJSON, err := json.Marshal(keys)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPut, userPublicKeysPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, apiToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, userPublicKeysPath, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, apiToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	keys = nil
+	err = json.Unmarshal(rr.Body.Bytes(), &keys)
+	assert.NoError(t, err)
+	assert.Len(t, keys, 2)
+
+	req, err = http.NewRequest(http.MethodPut, userPublicKeysPath, bytes.NewBuffer([]byte(`invalid json`)))
+	assert.NoError(t, err)
+	setBearerForReq(req, apiToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	keys = []string{`not a public key`}
+	asJSON, err = json.Marshal(keys)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPut, userPublicKeysPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, apiToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "could not parse key")
+
+	user.Filters.WebClient = append(user.Filters.WebClient, dataprovider.WebClientPubKeyChangeDisabled)
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+
+	apiToken, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodGet, userPublicKeysPath, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, apiToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestWebClientChangePubKeys(t *testing.T) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
@@ -4903,6 +5279,7 @@ func TestWebClientChangePubKeys(t *testing.T) {
 	form.Set(csrfFormToken, csrfToken)
 	form.Set("public_keys", testPubKey)
 	req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode())))
+	req.RequestURI = webChangeClientKeysPath
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
@@ -4939,6 +5316,8 @@ func TestPreDownloadHook(t *testing.T) {
 
 	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.NoError(t, err)
+	webAPIToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
 	req, err := http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil)
 	assert.NoError(t, err)
 	setJWTCookieForReq(req, webToken)
@@ -4946,6 +5325,13 @@ func TestPreDownloadHook(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 	assert.Equal(t, testFileContents, rr.Body.Bytes())
 
+	req, err = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testFileName, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Equal(t, testFileContents, rr.Body.Bytes())
+
 	err = os.WriteFile(preDownloadPath, getExitCodeScriptContent(1), os.ModePerm)
 	assert.NoError(t, err)
 	req, err = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil)
@@ -4955,6 +5341,13 @@ func TestPreDownloadHook(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 	assert.Contains(t, rr.Body.String(), "permission denied")
 
+	req, err = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testFileName, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "permission denied")
+
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
 	err = os.RemoveAll(user.GetHomeDir())
@@ -4981,6 +5374,8 @@ func TestWebGetFiles(t *testing.T) {
 	assert.NoError(t, err)
 	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.NoError(t, err)
+	webAPIToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
 	req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil)
 	setJWTCookieForReq(req, webToken)
 	rr := executeRequest(req)
@@ -5000,19 +5395,42 @@ func TestWebGetFiles(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Len(t, dirContents, 1)
 
-	req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath+"?path="+url.QueryEscape("/")+"&files="+
+	req, _ = http.NewRequest(http.MethodGet, userReadFolderPath+"?path="+testDir, nil)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	var dirEntries []map[string]interface{}
+	err = json.Unmarshal(rr.Body.Bytes(), &dirEntries)
+	assert.NoError(t, err)
+	assert.Len(t, dirEntries, 1)
+
+	req, _ = http.NewRequest(http.MethodGet, webClientDownloadZipPath+"?path="+url.QueryEscape("/")+"&files="+
 		url.QueryEscape(fmt.Sprintf(`["%v","%v","%v"]`, testFileName, testDir, testFileName+extensions[2])), nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 
-	req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath+"?path="+url.QueryEscape("/")+"&files="+
+	filesList := []string{testFileName, testDir, testFileName + extensions[2]}
+	asJSON, err := json.Marshal(filesList)
+	assert.NoError(t, err)
+	req, _ = http.NewRequest(http.MethodPost, userStreamZipPath, bytes.NewBuffer(asJSON))
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	assert.NoError(t, err)
+	req, _ = http.NewRequest(http.MethodPost, userStreamZipPath, bytes.NewBuffer([]byte(`file`)))
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	req, _ = http.NewRequest(http.MethodGet, webClientDownloadZipPath+"?path="+url.QueryEscape("/")+"&files="+
 		url.QueryEscape(fmt.Sprintf(`["%v"]`, testDir)), nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 
-	req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath+"?path="+url.QueryEscape("/")+"&files=notalist", nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientDownloadZipPath+"?path="+url.QueryEscape("/")+"&files=notalist", nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusInternalServerError, rr)
@@ -5027,10 +5445,26 @@ func TestWebGetFiles(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Len(t, dirContents, len(extensions)+1)
 
+	req, _ = http.NewRequest(http.MethodGet, userReadFolderPath+"?path=/", nil)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	dirEntries = nil
+	err = json.Unmarshal(rr.Body.Bytes(), &dirEntries)
+	assert.NoError(t, err)
+	assert.Len(t, dirEntries, len(extensions)+1)
+
 	req, _ = http.NewRequest(http.MethodGet, webClientDirContentsPath+"?path=/missing", nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
-	checkResponseCode(t, http.StatusInternalServerError, rr)
+	checkResponseCode(t, http.StatusNotFound, rr)
+	assert.Contains(t, rr.Body.String(), "Unable to get directory contents")
+
+	req, _ = http.NewRequest(http.MethodGet, userReadFolderPath+"?path=missing", nil)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+	assert.Contains(t, rr.Body.String(), "Unable to get directory contents")
 
 	req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil)
 	setJWTCookieForReq(req, webToken)
@@ -5038,6 +5472,30 @@ func TestWebGetFiles(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 	assert.Equal(t, testFileContents, rr.Body.Bytes())
 
+	req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testFileName, nil)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Equal(t, testFileContents, rr.Body.Bytes())
+
+	req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path=", nil)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Please set the path to a valid file")
+
+	req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testDir, nil)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "is a directory")
+
+	req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path=notafile", nil)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+	assert.Contains(t, rr.Body.String(), "Unable to stat the requested file")
+
 	req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil)
 	req.Header.Set("Range", "bytes=2-")
 	setJWTCookieForReq(req, webToken)
@@ -5045,6 +5503,13 @@ func TestWebGetFiles(t *testing.T) {
 	checkResponseCode(t, http.StatusPartialContent, rr)
 	assert.Equal(t, testFileContents[2:], rr.Body.Bytes())
 
+	req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testFileName, nil)
+	req.Header.Set("Range", "bytes=2-")
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusPartialContent, rr)
+	assert.Equal(t, testFileContents[2:], rr.Body.Bytes())
+
 	req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil)
 	req.Header.Set("Range", "bytes=-2")
 	setJWTCookieForReq(req, webToken)
@@ -5064,6 +5529,12 @@ func TestWebGetFiles(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusRequestedRangeNotSatisfiable, rr)
 
+	req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testFileName, nil)
+	req.Header.Set("Range", "bytes=2b-")
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusRequestedRangeNotSatisfiable, rr)
+
 	req, _ = http.NewRequest(http.MethodHead, webClientFilesPath+"?path="+testFileName, nil)
 	req.Header.Set("Range", "bytes=2-")
 	req.Header.Set("If-Range", time.Now().UTC().Add(120*time.Second).Format(http.TimeFormat))
@@ -5096,6 +5567,12 @@ func TestWebGetFiles(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusPreconditionFailed, rr)
 
+	req, _ = http.NewRequest(http.MethodHead, userGetFilePath+"?path="+testFileName, nil)
+	req.Header.Set("If-Unmodified-Since", time.Now().UTC().Add(-120*time.Second).Format(http.TimeFormat))
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusPreconditionFailed, rr)
+
 	req, _ = http.NewRequest(http.MethodHead, webClientFilesPath+"?path="+testFileName, nil)
 	req.Header.Set("If-Unmodified-Since", time.Now().UTC().Add(120*time.Second).Format(http.TimeFormat))
 	setJWTCookieForReq(req, webToken)
@@ -5111,6 +5588,29 @@ func TestWebGetFiles(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
+	req, _ = http.NewRequest(http.MethodGet, webClientDirContentsPath+"?path=/", nil)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+
+	req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testFileName, nil)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+
+	req, _ = http.NewRequest(http.MethodGet, userReadFolderPath+"?path="+testDir, nil)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+
+	filesList = []string{testDir}
+	asJSON, err = json.Marshal(filesList)
+	assert.NoError(t, err)
+	req, _ = http.NewRequest(http.MethodPost, userStreamZipPath, bytes.NewBuffer(asJSON))
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+
 	user.Filters.DeniedProtocols = []string{common.ProtocolFTP}
 	user.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword}
 	_, resp, err = httpdtest.UpdateUser(user, http.StatusOK, "")
@@ -5121,6 +5621,16 @@ func TestWebGetFiles(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
+	req, _ = http.NewRequest(http.MethodGet, webClientDownloadZipPath, nil)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+
+	req, _ = http.NewRequest(http.MethodGet, userReadFolderPath+"?path="+testDir, nil)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
 	err = os.RemoveAll(user.GetHomeDir())
@@ -5143,7 +5653,7 @@ func TestCompressionErrorMock(t *testing.T) {
 	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.NoError(t, err)
 
-	req, _ := http.NewRequest(http.MethodGet, webClientDownloadPath+"?path="+url.QueryEscape("/")+"&files="+
+	req, _ := http.NewRequest(http.MethodGet, webClientDownloadZipPath+"?path="+url.QueryEscape("/")+"&files="+
 		url.QueryEscape(`["missing"]`), nil)
 	setJWTCookieForReq(req, webToken)
 	executeRequest(req)
@@ -8085,11 +8595,26 @@ func setJWTCookieForReq(req *http.Request, jwtToken string) {
 }
 
 func getJWTAPITokenFromTestServer(username, password string) (string, error) {
-	req, _ := http.NewRequest(http.MethodGet, "/api/v2/token", nil)
+	req, _ := http.NewRequest(http.MethodGet, tokenPath, nil)
 	req.SetBasicAuth(username, password)
 	rr := executeRequest(req)
 	if rr.Code != http.StatusOK {
-		return "", fmt.Errorf("unexpected  status code %v", rr)
+		return "", fmt.Errorf("unexpected  status code %v", rr.Code)
+	}
+	responseHolder := make(map[string]interface{})
+	err := render.DecodeJSON(rr.Body, &responseHolder)
+	if err != nil {
+		return "", err
+	}
+	return responseHolder["access_token"].(string), nil
+}
+
+func getJWTAPIUserTokenFromTestServer(username, password string) (string, error) {
+	req, _ := http.NewRequest(http.MethodGet, userTokenPath, nil)
+	req.SetBasicAuth(username, password)
+	rr := executeRequest(req)
+	if rr.Code != http.StatusOK {
+		return "", fmt.Errorf("unexpected  status code %v", rr.Code)
 	}
 	responseHolder := make(map[string]interface{})
 	err := render.DecodeJSON(rr.Body, &responseHolder)

+ 59 - 24
httpd/internal_test.go

@@ -306,6 +306,18 @@ func TestGetRespStatus(t *testing.T) {
 	assert.Equal(t, http.StatusInternalServerError, respStatus)
 }
 
+func TestMappedStatusCode(t *testing.T) {
+	err := os.ErrPermission
+	code := getMappedStatusCode(err)
+	assert.Equal(t, http.StatusForbidden, code)
+	err = os.ErrNotExist
+	code = getMappedStatusCode(err)
+	assert.Equal(t, http.StatusNotFound, code)
+	err = os.ErrClosed
+	code = getMappedStatusCode(err)
+	assert.Equal(t, http.StatusInternalServerError, code)
+}
+
 func TestGCSWebInvalidFormFile(t *testing.T) {
 	form := make(url.Values)
 	form.Set("username", "test_username")
@@ -337,7 +349,7 @@ func TestInvalidToken(t *testing.T) {
 	deleteAdmin(rr, req)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 
-	adminPwd := adminPwd{
+	adminPwd := pwdChange{
 		CurrentPassword: "old",
 		NewPassword:     "new",
 	}
@@ -351,6 +363,31 @@ func TestInvalidToken(t *testing.T) {
 	assert.Equal(t, http.StatusInternalServerError, rr.Code)
 	adm := getAdminFromToken(req)
 	assert.Empty(t, adm.Username)
+
+	rr = httptest.NewRecorder()
+	readUserFolder(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	getUserFile(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	getUserFilesAsZipStream(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	getUserPublicKeys(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	setUserPublicKeys(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 }
 
 func TestUpdateWebAdminInvalidClaims(t *testing.T) {
@@ -441,6 +478,16 @@ func TestCreateTokenError(t *testing.T) {
 	server.generateAndSendToken(rr, req, admin)
 	assert.Equal(t, http.StatusInternalServerError, rr.Code)
 
+	rr = httptest.NewRecorder()
+	user := dataprovider.User{
+		Username: "u",
+		Password: "pwd",
+	}
+	req, _ = http.NewRequest(http.MethodGet, userTokenPath, nil)
+
+	server.generateAndSendUserToken(rr, req, "", user)
+	assert.Equal(t, http.StatusInternalServerError, rr.Code)
+
 	rr = httptest.NewRecorder()
 	form := make(url.Values)
 	form.Set("username", admin.Username)
@@ -492,7 +539,7 @@ func TestCreateTokenError(t *testing.T) {
 	assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String())
 
 	username := "webclientuser"
-	user := dataprovider.User{
+	user = dataprovider.User{
 		Username:    username,
 		Password:    "clientpwd",
 		HomeDir:     filepath.Join(os.TempDir(), username),
@@ -569,7 +616,7 @@ func TestJWTTokenValidation(t *testing.T) {
 	fn.ServeHTTP(rr, req.WithContext(ctx))
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 
-	permClientFn := checkClientPerm(dataprovider.WebClientPubKeyChangeDisabled)
+	permClientFn := checkHTTPUserPerm(dataprovider.WebClientPubKeyChangeDisabled)
 	fn = permClientFn(r)
 	rr = httptest.NewRecorder()
 	req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, nil)
@@ -577,6 +624,13 @@ func TestJWTTokenValidation(t *testing.T) {
 	ctx = jwtauth.NewContext(req.Context(), token, errTest)
 	fn.ServeHTTP(rr, req.WithContext(ctx))
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
+
+	rr = httptest.NewRecorder()
+	req, _ = http.NewRequest(http.MethodPost, userPublicKeysPath, nil)
+	req.RequestURI = userPublicKeysPath
+	ctx = jwtauth.NewContext(req.Context(), token, errTest)
+	fn.ServeHTTP(rr, req.WithContext(ctx))
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
 }
 
 func TestUpdateContextFromCookie(t *testing.T) {
@@ -1385,25 +1439,6 @@ func TestConnection(t *testing.T) {
 	assert.ErrorIs(t, err, os.ErrNotExist)
 }
 
-func TestRenderDirError(t *testing.T) {
-	user := dataprovider.User{
-		Username: "test_httpd_user",
-		HomeDir:  filepath.Clean(os.TempDir()),
-	}
-	user.Permissions = make(map[string][]string)
-	user.Permissions["/"] = []string{dataprovider.PermAny}
-	connection := &Connection{
-		BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", user),
-		request:        nil,
-	}
-
-	rr := httptest.NewRecorder()
-	req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil)
-	renderDirContents(rr, req, connection, "missing dir")
-	assert.Equal(t, http.StatusOK, rr.Code)
-	assert.Contains(t, rr.Body.String(), "text-form-error")
-}
-
 func TestHTTPDFile(t *testing.T) {
 	user := dataprovider.User{
 		Username: "test_httpd_user",
@@ -1490,9 +1525,9 @@ func TestGetFilesInvalidClaims(t *testing.T) {
 	assert.Contains(t, rr.Body.String(), "invalid token claims")
 
 	rr = httptest.NewRecorder()
-	req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath, nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientDownloadZipPath, nil)
 	req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
-	handleWebClientDownload(rr, req)
+	handleWebClientDownloadZip(rr, req)
 	assert.Equal(t, http.StatusForbidden, rr.Code)
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 }

+ 29 - 7
httpd/middleware.go

@@ -36,9 +36,11 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi
 		redirectPath = webClientLoginPath
 	}
 
+	isAPIToken := (audience == tokenAudienceAPI || audience == tokenAudienceAPIUser)
+
 	if err != nil || token == nil {
 		logger.Debug(logSender, "", "error getting jwt token: %v", err)
-		if audience == tokenAudienceAPI {
+		if isAPIToken {
 			sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
 		} else {
 			http.Redirect(w, r, redirectPath, http.StatusFound)
@@ -49,7 +51,7 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi
 	err = jwt.Validate(token)
 	if err != nil {
 		logger.Debug(logSender, "", "error validating jwt token: %v", err)
-		if audience == tokenAudienceAPI {
+		if isAPIToken {
 			sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
 		} else {
 			http.Redirect(w, r, redirectPath, http.StatusFound)
@@ -58,7 +60,7 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi
 	}
 	if !utils.IsStringInSlice(audience, token.Audience()) {
 		logger.Debug(logSender, "", "the token is not valid for audience %#v", audience)
-		if audience == tokenAudienceAPI {
+		if isAPIToken {
 			sendAPIResponse(w, r, nil, "Your token audience is not valid", http.StatusUnauthorized)
 		} else {
 			http.Redirect(w, r, redirectPath, http.StatusFound)
@@ -67,7 +69,7 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi
 	}
 	if isTokenInvalidated(r) {
 		logger.Debug(logSender, "", "the token has been invalidated")
-		if audience == tokenAudienceAPI {
+		if isAPIToken {
 			sendAPIResponse(w, r, nil, "Your token is no longer valid", http.StatusUnauthorized)
 		} else {
 			http.Redirect(w, r, redirectPath, http.StatusFound)
@@ -88,6 +90,17 @@ func jwtAuthenticatorAPI(next http.Handler) http.Handler {
 	})
 }
 
+func jwtAuthenticatorAPIUser(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if err := validateJWTToken(w, r, tokenAudienceAPIUser); err != nil {
+			return
+		}
+
+		// Token is authenticated, pass it through
+		next.ServeHTTP(w, r)
+	})
+}
+
 func jwtAuthenticatorWebAdmin(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if err := validateJWTToken(w, r, tokenAudienceWebAdmin); err != nil {
@@ -110,19 +123,28 @@ func jwtAuthenticatorWebClient(next http.Handler) http.Handler {
 	})
 }
 
-func checkClientPerm(perm string) func(next http.Handler) http.Handler {
+//nolint:unparam
+func checkHTTPUserPerm(perm string) func(next http.Handler) http.Handler {
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 			_, claims, err := jwtauth.FromContext(r.Context())
 			if err != nil {
-				renderClientBadRequestPage(w, r, err)
+				if isWebRequest(r) {
+					renderClientBadRequestPage(w, r, err)
+				} else {
+					sendAPIResponse(w, r, err, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+				}
 				return
 			}
 			tokenClaims := jwtTokenClaims{}
 			tokenClaims.Decode(claims)
 			// for web client perms are negated and not granted
 			if tokenClaims.hasPerm(perm) {
-				renderClientForbiddenPage(w, r, "You don't have permission for this action")
+				if isWebRequest(r) {
+					renderClientForbiddenPage(w, r, "You don't have permission for this action")
+				} else {
+					sendAPIResponse(w, r, nil, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+				}
 				return
 			}
 

+ 295 - 5
httpd/schema/openapi.yaml

@@ -9,6 +9,7 @@ tags:
   - name: quota
   - name: folders
   - name: users
+  - name: users API
 info:
   title: SFTPGo
   description: |
@@ -16,7 +17,7 @@ info:
     Several storage backends are supported and they are configurable per user, so you can serve a local directory for a user and an S3 bucket (or part of it) for another one.
     SFTPGo also supports virtual folders, a virtual folder can use any of the supported storage backends. So you can have, for example, an S3 user that exposes a GCS bucket (or part of it) on a specified path and an encrypted local filesystem on another one.
     Virtual folders can be private or shared among multiple users, for shared virtual folders you can define different quota limits for each user.
-  version: 2.0.5
+  version: 2.0.9
   contact:
     name: API support
     url: 'https://github.com/drakkan/sftpgo'
@@ -52,7 +53,7 @@ paths:
         - BasicAuth: []
       tags:
         - token
-      summary: Get a new access token
+      summary: Get a new admin access token
       description: Returns an access token and its expiration
       operationId: get_token
       responses:
@@ -74,8 +75,8 @@ paths:
     get:
       tags:
         - token
-      summary: Invalidate
-      description: Allows to invalidate a token before its expiration
+      summary: Invalidate an admin access token
+      description: Allows to invalidate an admin token before its expiration
       operationId: logout
       responses:
         '200':
@@ -92,6 +93,52 @@ paths:
           $ref: '#/components/responses/InternalServerError'
         default:
           $ref: '#/components/responses/DefaultResponse'
+  /user/token:
+    get:
+      security:
+        - BasicAuth: []
+      tags:
+        - token
+      summary: Get a new user access token
+      description: Returns an access token and its expiration
+      operationId: get_user_token
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Token'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /user/logout:
+    get:
+      tags:
+        - token
+      summary: Invalidate a user access token
+      description: Allows to invalidate a client token before its expiration
+      operationId: client_logout
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /version:
     get:
       tags:
@@ -115,6 +162,35 @@ paths:
         default:
           $ref: '#/components/responses/DefaultResponse'
   /changepwd/admin:
+    put:
+      tags:
+        - admins
+      summary: Change admin password
+      description: Changes the password for the logged in admin. Please use /admin/changepwd instead
+      operationId: change_admin_pwd
+      deprecated: true
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/PwdChange'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /admin/changepwd:
     put:
       tags:
         - admins
@@ -1242,6 +1318,198 @@ paths:
           $ref: '#/components/responses/InternalServerError'
         default:
           $ref: '#/components/responses/DefaultResponse'
+  /user/changepwd:
+    put:
+      tags:
+        - users API
+      summary: Change user password
+      description: Changes the password for the logged in user
+      operationId: change_user_password
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/PwdChange'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /user/publickeys:
+    get:
+      tags:
+        - users API
+      summary: Get the user's public keys
+      description: Returns the public keys for the logged in user
+      operationId: get_user_public_keys
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  type: string
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    put:
+      tags:
+        - users API
+      summary: Set the user's public keys
+      description: Sets the public keys for the logged in user. Public keys must be in OpenSSH format
+      operationId: set_user_public_keys
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: array
+              items:
+                type: string
+                description: Public key in OpenSSH format
+                example: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPVILdH2u3yV5SAeE6XksD1z1vXRg0E4hJUov8ITDAZ2 user@host
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /user/folder:
+    get:
+      tags:
+        - users API
+      summary: Read folders contents
+      description: Returns the contents of the specified folder for the logged in user
+      operationId: get_user_folder_contents
+      parameters:
+        - in: query
+          name: path
+          description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the root folder is assumed
+          schema:
+            type: string
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/DirEntry'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /user/file:
+    get:
+      tags:
+        - users API
+      summary: Download a single file
+      description: Returns the file contents as response body
+      operationId: get_user_file
+      parameters:
+        - in: query
+          name: path
+          required: true
+          description: Path to the file to download. It must be URL encoded, for example the path "my dir/àdir/file.txt" must be sent as "my%20dir%2F%C3%A0dir%2Ffile.txt"
+          schema:
+            type: string
+      responses:
+        '200':
+          description: successful operation
+          content:
+            '*/*':
+              schema:
+                type: string
+                format: binary
+        '206':
+          description: successful operation
+          content:
+            '*/*':
+              schema:
+                type: string
+                format: binary
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /user/streamzip:
+    post:
+      tags:
+        - users API
+      summary: Download multiple files and folders as a single zip file
+      description: A zip file, containing the specified files and folders, will be generated on the fly and returned as response body. Only folders and regular files will be included in the zip
+      operationId: streamzip
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: array
+              items:
+                type: string
+                description: Absolute file or folder path
+      responses:
+        '200':
+          description: successful operation
+          content:
+            'application/zip':
+              schema:
+                type: string
+                format: binary
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
 components:
   responses:
     BadRequest:
@@ -1751,7 +2019,8 @@ components:
           type: array
           items:
             type: string
-          description: a password or at least one public key/SSH user certificate are mandatory.
+            example: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEUWwDwEWhTbF0MqAsp/oXK1HR2cElhM8oo1uVmL3ZeDKDiTm4ljMr92wfTgIGDqIoxmVqgYIkAOAhuykAVWBzc= user@host
+          description: Public keys in OpenSSH format. A password or at least one public key/SSH user certificate are mandatory.
         home_dir:
           type: string
           description: path to the user home directory. The user cannot upload or download files outside this directory. SFTPGo tries to automatically create this folder if missing. Must be an absolute path
@@ -2124,6 +2393,27 @@ components:
           type: string
         new_password:
           type: string
+    DirEntry:
+      type: object
+      properties:
+        name:
+          type: string
+          description: name of the file (or subdirectory) described by the entry. This name is the final element of the path (the base name), not the entire path
+        size:
+          type: integer
+          format: int64
+          description: file size, omitted for folders and non regular files
+        mode:
+          type: integer
+          description: |
+            File mode and permission bits. More details here: https://golang.org/pkg/io/fs/#FileMode.
+            Let's see some examples:
+            - for a directory mode&2147483648 != 0
+            - for a symlink mode&134217728 != 0
+            - for a regular file mode&2401763328 == 0
+        last_modified:
+          type: string
+          format: date-time
     ApiResponse:
       type: object
       properties:

+ 93 - 7
httpd/server.go

@@ -120,18 +120,20 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 		renderClientLoginPage(w, err.Error())
 		return
 	}
+	ipAddr := utils.GetIPFromRemoteAddress(r.RemoteAddr)
 	username := r.Form.Get("username")
 	password := r.Form.Get("password")
 	if username == "" || password == "" {
+		updateLoginMetrics(&dataprovider.User{Username: username}, ipAddr, common.ErrNoCredentials)
 		renderClientLoginPage(w, "Invalid credentials")
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
+		updateLoginMetrics(&dataprovider.User{Username: username}, ipAddr, err)
 		renderClientLoginPage(w, err.Error())
 		return
 	}
 
-	ipAddr := utils.GetIPFromRemoteAddress(r.RemoteAddr)
 	if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolHTTP); err != nil {
 		renderClientLoginPage(w, fmt.Sprintf("access denied by post connect hook: %v", err))
 		return
@@ -144,7 +146,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 		return
 	}
 	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String())
-	if err := checkWebClientUser(&user, r, connectionID); err != nil {
+	if err := checkHTTPClientUser(&user, r, connectionID); err != nil {
 		updateLoginMetrics(&user, ipAddr, err)
 		renderClientLoginPage(w, err.Error())
 		return
@@ -154,7 +156,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 	err = user.CheckFsRoot(connectionID)
 	if err != nil {
 		logger.Warn(logSender, connectionID, "unable to check fs root: %v", err)
-		updateLoginMetrics(&user, ipAddr, err)
+		updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure)
 		renderClientLoginPage(w, err.Error())
 		return
 	}
@@ -168,11 +170,12 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 	err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebClient)
 	if err != nil {
 		logger.Warn(logSender, connectionID, "unable to set client login cookie %v", err)
-		updateLoginMetrics(&user, ipAddr, err)
+		updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure)
 		renderClientLoginPage(w, err.Error())
 		return
 	}
 	updateLoginMetrics(&user, ipAddr, err)
+	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
 	http.Redirect(w, r, webClientFilesPath, http.StatusFound)
 }
 
@@ -266,6 +269,72 @@ func (s *httpdServer) logout(w http.ResponseWriter, r *http.Request) {
 	sendAPIResponse(w, r, nil, "Your token has been invalidated", http.StatusOK)
 }
 
+func (s *httpdServer) getUserToken(w http.ResponseWriter, r *http.Request) {
+	ipAddr := utils.GetIPFromRemoteAddress(r.RemoteAddr)
+	username, password, ok := r.BasicAuth()
+	if !ok {
+		updateLoginMetrics(&dataprovider.User{Username: username}, ipAddr, common.ErrNoCredentials)
+		w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
+		sendAPIResponse(w, r, nil, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+		return
+	}
+	if username == "" || password == "" {
+		updateLoginMetrics(&dataprovider.User{Username: username}, ipAddr, common.ErrNoCredentials)
+		w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
+		sendAPIResponse(w, r, nil, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+		return
+	}
+	if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolHTTP); err != nil {
+		sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+	user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, common.ProtocolHTTP)
+	if err != nil {
+		w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
+		updateLoginMetrics(&user, ipAddr, err)
+		sendAPIResponse(w, r, dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized),
+			http.StatusUnauthorized)
+		return
+	}
+	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String())
+	if err := checkHTTPClientUser(&user, r, connectionID); err != nil {
+		updateLoginMetrics(&user, ipAddr, err)
+		sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	defer user.CloseFs() //nolint:errcheck
+	err = user.CheckFsRoot(connectionID)
+	if err != nil {
+		logger.Warn(logSender, connectionID, "unable to check fs root: %v", err)
+		updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure)
+		sendAPIResponse(w, r, err, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+		return
+	}
+
+	s.generateAndSendUserToken(w, r, ipAddr, user)
+}
+
+func (s *httpdServer) generateAndSendUserToken(w http.ResponseWriter, r *http.Request, ipAddr string, user dataprovider.User) {
+	c := jwtTokenClaims{
+		Username:    user.Username,
+		Permissions: user.Filters.WebClient,
+		Signature:   user.GetSignature(),
+	}
+
+	resp, err := c.createTokenResponse(s.tokenAuth, tokenAudienceAPIUser)
+
+	if err != nil {
+		updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure)
+		sendAPIResponse(w, r, err, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+		return
+	}
+	updateLoginMetrics(&user, ipAddr, err)
+	dataprovider.UpdateLastLogin(&user) //nolint:errcheck
+
+	render.JSON(w, r, resp)
+}
+
 func (s *httpdServer) getToken(w http.ResponseWriter, r *http.Request) {
 	username, password, ok := r.BasicAuth()
 	if !ok {
@@ -329,7 +398,7 @@ func (s *httpdServer) refreshClientToken(w http.ResponseWriter, r *http.Request,
 		logger.Debug(logSender, "", "signature mismatch for user %#v, unable to refresh cookie", user.Username)
 		return
 	}
-	if err := checkWebClientUser(&user, r, xid.New().String()); err != nil {
+	if err := checkHTTPClientUser(&user, r, xid.New().String()); err != nil {
 		logger.Debug(logSender, "", "unable to refresh cookie for user %#v: %v", user.Username, err)
 		return
 	}
@@ -496,6 +565,8 @@ func (s *httpdServer) initializeRouter() {
 
 		router.Get(logoutPath, s.logout)
 		router.Put(adminPwdPath, changeAdminPassword)
+		// compatibility layer to remove in v2.2
+		router.Put(adminPwdCompatPath, changeAdminPassword)
 
 		router.With(checkPerm(dataprovider.PermAdminViewServerStatus)).
 			Get(serverStatusPath, func(w http.ResponseWriter, r *http.Request) {
@@ -538,6 +609,21 @@ func (s *httpdServer) initializeRouter() {
 		router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Delete(adminPath+"/{username}", deleteAdmin)
 	})
 
+	s.router.Get(userTokenPath, s.getUserToken)
+
+	s.router.Group(func(router chi.Router) {
+		router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromHeader))
+		router.Use(jwtAuthenticatorAPIUser)
+
+		router.Get(userLogoutPath, s.logout)
+		router.Put(userPwdPath, changeUserPassword)
+		router.With(checkHTTPUserPerm(dataprovider.WebClientPubKeyChangeDisabled)).Get(userPublicKeysPath, getUserPublicKeys)
+		router.With(checkHTTPUserPerm(dataprovider.WebClientPubKeyChangeDisabled)).Put(userPublicKeysPath, setUserPublicKeys)
+		router.Get(userReadFolderPath, readUserFolder)
+		router.Get(userGetFilePath, getUserFile)
+		router.Post(userStreamZipPath, getUserFilesAsZipStream)
+	})
+
 	if s.enableWebAdmin || s.enableWebClient {
 		s.router.Group(func(router chi.Router) {
 			router.Use(compressor.Handler)
@@ -574,10 +660,10 @@ func (s *httpdServer) initializeRouter() {
 			router.Get(webClientLogoutPath, handleWebClientLogout)
 			router.With(s.refreshCookie).Get(webClientFilesPath, handleClientGetFiles)
 			router.With(compressor.Handler, s.refreshCookie).Get(webClientDirContentsPath, handleClientGetDirContents)
-			router.With(s.refreshCookie).Get(webClientDownloadPath, handleWebClientDownload)
+			router.With(s.refreshCookie).Get(webClientDownloadZipPath, handleWebClientDownloadZip)
 			router.With(s.refreshCookie).Get(webClientCredentialsPath, handleClientGetCredentials)
 			router.Post(webChangeClientPwdPath, handleWebClientChangePwdPost)
-			router.With(checkClientPerm(dataprovider.WebClientPubKeyChangeDisabled)).
+			router.With(checkHTTPUserPerm(dataprovider.WebClientPubKeyChangeDisabled)).
 				Post(webChangeClientKeysPath, handleWebClientManageKeysPost)
 		})
 	}

+ 33 - 266
httpd/webclient.go

@@ -2,18 +2,13 @@ package httpd
 
 import (
 	"encoding/json"
-	"errors"
 	"fmt"
 	"html/template"
-	"io"
-	"mime"
 	"net/http"
 	"net/url"
 	"os"
 	"path"
 	"path/filepath"
-	"strconv"
-	"strings"
 	"time"
 
 	"github.com/go-chi/render"
@@ -21,8 +16,6 @@ import (
 
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/dataprovider"
-	"github.com/drakkan/sftpgo/logger"
-	"github.com/drakkan/sftpgo/metrics"
 	"github.com/drakkan/sftpgo/utils"
 	"github.com/drakkan/sftpgo/version"
 	"github.com/drakkan/sftpgo/vfs"
@@ -83,7 +76,6 @@ type filesPage struct {
 	CurrentDir  string
 	ReadDirURL  string
 	DownloadURL string
-	Files       []os.FileInfo
 	Error       string
 	Paths       []dirMapping
 }
@@ -215,13 +207,12 @@ func renderClientNotFoundPage(w http.ResponseWriter, r *http.Request, err error)
 	renderClientMessagePage(w, r, page404Title, page404Body, http.StatusNotFound, err, "")
 }
 
-func renderFilesPage(w http.ResponseWriter, r *http.Request, files []os.FileInfo, dirName, error string) {
+func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string) {
 	data := filesPage{
 		baseClientPage: getBaseClientPageData(pageClientFilesTitle, webClientFilesPath, r),
-		Files:          files,
 		Error:          error,
 		CurrentDir:     url.QueryEscape(dirName),
-		DownloadURL:    webClientDownloadPath,
+		DownloadURL:    webClientDownloadZipPath,
 		ReadDirURL:     webClientDirContentsPath,
 	}
 	paths := []dirMapping{}
@@ -272,7 +263,7 @@ func handleWebClientLogout(w http.ResponseWriter, r *http.Request) {
 	http.Redirect(w, r, webClientLoginPath, http.StatusFound)
 }
 
-func handleWebClientDownload(w http.ResponseWriter, r *http.Request) {
+func handleWebClientDownloadZip(w http.ResponseWriter, r *http.Request) {
 	claims, err := getTokenClaims(r)
 	if err != nil || claims.Username == "" {
 		renderClientMessagePage(w, r, "Invalid token claims", "", http.StatusForbidden, nil, "")
@@ -281,12 +272,18 @@ func handleWebClientDownload(w http.ResponseWriter, r *http.Request) {
 
 	user, err := dataprovider.UserExists(claims.Username)
 	if err != nil {
-		renderClientMessagePage(w, r, "Unable to retrieve your user", "", http.StatusInternalServerError, nil, "")
+		renderClientMessagePage(w, r, "Unable to retrieve your user", "", getRespStatus(err), nil, "")
 		return
 	}
 
+	connID := xid.New().String()
+	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID)
+	if err := checkHTTPClientUser(&user, r, connectionID); err != nil {
+		renderClientForbiddenPage(w, r, err.Error())
+		return
+	}
 	connection := &Connection{
-		BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, r.RemoteAddr, user),
+		BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, r.RemoteAddr, user),
 		request:        r,
 	}
 	common.Connections.Add(connection)
@@ -318,12 +315,18 @@ func handleClientGetDirContents(w http.ResponseWriter, r *http.Request) {
 
 	user, err := dataprovider.UserExists(claims.Username)
 	if err != nil {
-		sendAPIResponse(w, r, nil, "unable to retrieve your user", http.StatusInternalServerError)
+		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
 		return
 	}
 
+	connID := xid.New().String()
+	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID)
+	if err := checkHTTPClientUser(&user, r, connectionID); err != nil {
+		sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
 	connection := &Connection{
-		BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, r.RemoteAddr, user),
+		BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, r.RemoteAddr, user),
 		request:        r,
 	}
 	common.Connections.Add(connection)
@@ -336,7 +339,7 @@ func handleClientGetDirContents(w http.ResponseWriter, r *http.Request) {
 
 	contents, err := connection.ReadDir(name)
 	if err != nil {
-		sendAPIResponse(w, r, nil, err.Error(), http.StatusInternalServerError)
+		sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err))
 		return
 	}
 
@@ -372,13 +375,13 @@ func handleClientGetFiles(w http.ResponseWriter, r *http.Request) {
 
 	user, err := dataprovider.UserExists(claims.Username)
 	if err != nil {
-		renderClientInternalServerErrorPage(w, r, errors.New("unable to retrieve your user"))
+		renderClientMessagePage(w, r, "Unable to retrieve your user", "", getRespStatus(err), nil, "")
 		return
 	}
 
 	connID := xid.New().String()
 	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID)
-	if err := checkWebClientUser(&user, r, connectionID); err != nil {
+	if err := checkHTTPClientUser(&user, r, connectionID); err != nil {
 		renderClientForbiddenPage(w, r, err.Error())
 		return
 	}
@@ -400,14 +403,22 @@ func handleClientGetFiles(w http.ResponseWriter, r *http.Request) {
 		info, err = connection.Stat(name, 0)
 	}
 	if err != nil {
-		renderFilesPage(w, r, nil, name, fmt.Sprintf("unable to stat file %#v: %v", name, err))
+		renderFilesPage(w, r, path.Dir(name), fmt.Sprintf("unable to stat file %#v: %v", name, err))
 		return
 	}
 	if info.IsDir() {
-		renderDirContents(w, r, connection, name)
+		renderFilesPage(w, r, name, "")
 		return
 	}
-	downloadFile(w, r, connection, name, info)
+	if status, err := downloadFile(w, r, connection, name, info); err != nil && status != 0 {
+		if status > 0 {
+			if status == http.StatusRequestedRangeNotSatisfiable {
+				renderClientMessagePage(w, r, http.StatusText(status), "", status, err, "")
+				return
+			}
+			renderFilesPage(w, r, path.Dir(name), err.Error())
+		}
+	}
 }
 
 func handleClientGetCredentials(w http.ResponseWriter, r *http.Request) {
@@ -463,247 +474,3 @@ func handleWebClientManageKeysPost(w http.ResponseWriter, r *http.Request) {
 	}
 	renderClientMessagePage(w, r, "Public keys updated", "", http.StatusOK, nil, "Your public keys has been successfully updated")
 }
-
-func doChangeUserPassword(r *http.Request, currentPassword, newPassword, confirmNewPassword string) error {
-	if currentPassword == "" || newPassword == "" || confirmNewPassword == "" {
-		return dataprovider.NewValidationError("please provide the current password and the new one two times")
-	}
-	if newPassword != confirmNewPassword {
-		return dataprovider.NewValidationError("the two password fields do not match")
-	}
-	if currentPassword == newPassword {
-		return dataprovider.NewValidationError("the new password must be different from the current one")
-	}
-	claims, err := getTokenClaims(r)
-	if err != nil || claims.Username == "" {
-		return errors.New("invalid token claims")
-	}
-	user, err := dataprovider.CheckUserAndPass(claims.Username, currentPassword, utils.GetIPFromRemoteAddress(r.RemoteAddr),
-		common.ProtocolHTTP)
-	if err != nil {
-		return dataprovider.NewValidationError("current password does not match")
-	}
-	user.Password = newPassword
-
-	return dataprovider.UpdateUser(&user)
-}
-
-func renderDirContents(w http.ResponseWriter, r *http.Request, connection *Connection, name string) {
-	contents, err := connection.ReadDir(name)
-	if err != nil {
-		renderFilesPage(w, r, nil, name, fmt.Sprintf("unable to get contents for directory %#v: %v", name, err))
-		return
-	}
-	renderFilesPage(w, r, contents, name, "")
-}
-
-func downloadFile(w http.ResponseWriter, r *http.Request, connection *Connection, name string, info os.FileInfo) {
-	var err error
-	rangeHeader := r.Header.Get("Range")
-	if rangeHeader != "" && checkIfRange(r, info.ModTime()) == condFalse {
-		rangeHeader = ""
-	}
-	offset := int64(0)
-	size := info.Size()
-	responseStatus := http.StatusOK
-	if strings.HasPrefix(rangeHeader, "bytes=") {
-		if strings.Contains(rangeHeader, ",") {
-			http.Error(w, fmt.Sprintf("unsupported range %#v", rangeHeader), http.StatusRequestedRangeNotSatisfiable)
-			return
-		}
-		offset, size, err = parseRangeRequest(rangeHeader[6:], size)
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusRequestedRangeNotSatisfiable)
-			return
-		}
-		responseStatus = http.StatusPartialContent
-	}
-	reader, err := connection.getFileReader(name, offset, r.Method)
-	if err != nil {
-		renderFilesPage(w, r, nil, name, fmt.Sprintf("unable to read file %#v: %v", name, err))
-		return
-	}
-	defer reader.Close()
-
-	w.Header().Set("Last-Modified", info.ModTime().UTC().Format(http.TimeFormat))
-	if checkPreconditions(w, r, info.ModTime()) {
-		return
-	}
-	ctype := mime.TypeByExtension(path.Ext(name))
-	if ctype == "" {
-		ctype = "application/octet-stream"
-	}
-	if responseStatus == http.StatusPartialContent {
-		w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, info.Size()))
-	}
-	w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
-	w.Header().Set("Content-Type", ctype)
-	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%#v", path.Base(name)))
-	w.Header().Set("Accept-Ranges", "bytes")
-	w.WriteHeader(responseStatus)
-	if r.Method != http.MethodHead {
-		io.CopyN(w, reader, size) //nolint:errcheck
-	}
-}
-
-func checkPreconditions(w http.ResponseWriter, r *http.Request, modtime time.Time) bool {
-	if checkIfUnmodifiedSince(r, modtime) == condFalse {
-		w.WriteHeader(http.StatusPreconditionFailed)
-		return true
-	}
-	if checkIfModifiedSince(r, modtime) == condFalse {
-		w.WriteHeader(http.StatusNotModified)
-		return true
-	}
-	return false
-}
-
-func checkIfUnmodifiedSince(r *http.Request, modtime time.Time) condResult {
-	ius := r.Header.Get("If-Unmodified-Since")
-	if ius == "" || isZeroTime(modtime) {
-		return condNone
-	}
-	t, err := http.ParseTime(ius)
-	if err != nil {
-		return condNone
-	}
-
-	// The Last-Modified header truncates sub-second precision so
-	// the modtime needs to be truncated too.
-	modtime = modtime.Truncate(time.Second)
-	if modtime.Before(t) || modtime.Equal(t) {
-		return condTrue
-	}
-	return condFalse
-}
-
-func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult {
-	if r.Method != http.MethodGet && r.Method != http.MethodHead {
-		return condNone
-	}
-	ims := r.Header.Get("If-Modified-Since")
-	if ims == "" || isZeroTime(modtime) {
-		return condNone
-	}
-	t, err := http.ParseTime(ims)
-	if err != nil {
-		return condNone
-	}
-	// The Last-Modified header truncates sub-second precision so
-	// the modtime needs to be truncated too.
-	modtime = modtime.Truncate(time.Second)
-	if modtime.Before(t) || modtime.Equal(t) {
-		return condFalse
-	}
-	return condTrue
-}
-
-func checkIfRange(r *http.Request, modtime time.Time) condResult {
-	if r.Method != http.MethodGet && r.Method != http.MethodHead {
-		return condNone
-	}
-	ir := r.Header.Get("If-Range")
-	if ir == "" {
-		return condNone
-	}
-	if modtime.IsZero() {
-		return condFalse
-	}
-	t, err := http.ParseTime(ir)
-	if err != nil {
-		return condFalse
-	}
-	if modtime.Add(60 * time.Second).Before(t) {
-		return condTrue
-	}
-	return condFalse
-}
-
-func parseRangeRequest(bytesRange string, size int64) (int64, int64, error) {
-	var start, end int64
-	var err error
-
-	values := strings.Split(bytesRange, "-")
-	if values[0] == "" {
-		start = -1
-	} else {
-		start, err = strconv.ParseInt(values[0], 10, 64)
-		if err != nil {
-			return start, size, err
-		}
-	}
-	if len(values) >= 2 {
-		if values[1] != "" {
-			end, err = strconv.ParseInt(values[1], 10, 64)
-			if err != nil {
-				return start, size, err
-			}
-			if end >= size {
-				end = size - 1
-			}
-		}
-	}
-	if start == -1 && end == 0 {
-		return 0, 0, fmt.Errorf("unsupported range %#v", bytesRange)
-	}
-
-	if end > 0 {
-		if start == -1 {
-			// we have something like -500
-			start = size - end
-			size = end
-			// start cannit be < 0 here, we did end = size -1 above
-		} else {
-			// we have something like 500-600
-			size = end - start + 1
-			if size < 0 {
-				return 0, 0, fmt.Errorf("unacceptable range %#v", bytesRange)
-			}
-		}
-		return start, size, nil
-	}
-	// we have something like 500-
-	size -= start
-	if size < 0 {
-		return 0, 0, fmt.Errorf("unacceptable range %#v", bytesRange)
-	}
-	return start, size, err
-}
-
-func updateLoginMetrics(user *dataprovider.User, ip string, err error) {
-	metrics.AddLoginAttempt(dataprovider.LoginMethodPassword)
-	if err != nil {
-		logger.ConnectionFailedLog(user.Username, ip, dataprovider.LoginMethodPassword, common.ProtocolHTTP, err.Error())
-		event := common.HostEventLoginFailed
-		if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
-			event = common.HostEventUserNotFound
-		}
-		common.AddDefenderEvent(ip, event)
-	}
-	metrics.AddLoginResult(dataprovider.LoginMethodPassword, err)
-	dataprovider.ExecutePostLoginHook(user, dataprovider.LoginMethodPassword, ip, common.ProtocolHTTP, err)
-}
-
-func checkWebClientUser(user *dataprovider.User, r *http.Request, connectionID string) error {
-	if utils.IsStringInSlice(common.ProtocolHTTP, user.Filters.DeniedProtocols) {
-		logger.Debug(logSender, connectionID, "cannot login user %#v, protocol HTTP is not allowed", user.Username)
-		return fmt.Errorf("protocol HTTP is not allowed for user %#v", user.Username)
-	}
-	if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) {
-		logger.Debug(logSender, connectionID, "cannot login user %#v, password login method is not allowed", user.Username)
-		return fmt.Errorf("login method password is not allowed for user %#v", user.Username)
-	}
-	if user.MaxSessions > 0 {
-		activeSessions := common.Connections.GetActiveSessions(user.Username)
-		if activeSessions >= user.MaxSessions {
-			logger.Debug(logSender, connectionID, "authentication refused for user: %#v, too many open sessions: %v/%v", user.Username,
-				activeSessions, user.MaxSessions)
-			return fmt.Errorf("too many open sessions: %v", activeSessions)
-		}
-	}
-	if !user.IsLoginFromAddrAllowed(r.RemoteAddr) {
-		logger.Debug(logSender, connectionID, "cannot login user %#v, remote address is not allowed: %v", user.Username, r.RemoteAddr)
-		return fmt.Errorf("login for user %#v is not allowed from this address: %v", user.Username, r.RemoteAddr)
-	}
-	return nil
-}

+ 1 - 1
httpdtest/httpdtest.go

@@ -42,7 +42,7 @@ const (
 	defenderUnban             = "/api/v2/defender/unban"
 	defenderScore             = "/api/v2/defender/score"
 	adminPath                 = "/api/v2/admins"
-	adminPwdPath              = "/api/v2/changepwd/admin"
+	adminPwdPath              = "/api/v2/admin/changepwd"
 )
 
 const (

+ 1 - 1
templates/webclient/files.html

@@ -172,7 +172,7 @@
         $.fn.dataTable.ext.buttons.download = {
             text: '<i class="fas fa-download"></i>',
             name: 'download',
-            titleAttr: "Download",
+            titleAttr: "Download Zip",
             action: function (e, dt, node, config) {
                 var filesArray = [];
                 var selected = dt.column(0).checkboxes.selected();

+ 3 - 0
vfs/sftpfs.go

@@ -269,6 +269,9 @@ func (fs *SFTPFs) Open(name string, offset int64) (File, *pipeat.PipeReaderAt, f
 		return nil, nil, nil, err
 	}
 	go func() {
+		// if we enable buffering the client stalls
+		//br := bufio.NewReaderSize(f, int(fs.config.BufferSize)*1024*1024)
+		//n, err := fs.copy(w, br)
 		n, err := io.Copy(w, f)
 		w.CloseWithError(err) //nolint:errcheck
 		f.Close()

+ 5 - 7
webdavd/server.go

@@ -26,10 +26,6 @@ import (
 	"github.com/drakkan/sftpgo/utils"
 )
 
-var (
-	err401 = errors.New("Unauthorized")
-)
-
 type webDavServer struct {
 	config  *Configuration
 	binding Binding
@@ -171,6 +167,7 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 	user, isCached, lockSystem, loginMethod, err := s.authenticate(r, ipAddr)
 	if err != nil {
+		updateLoginMetrics(&user, ipAddr, loginMethod, err)
 		w.Header().Set("WWW-Authenticate", "Basic realm=\"SFTPGo WebDAV\"")
 		http.Error(w, fmt.Sprintf("Authentication error: %v", err), http.StatusUnauthorized)
 		return
@@ -193,7 +190,7 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		errClose := user.CloseFs()
 		logger.Warn(logSender, connectionID, "unable to check fs root: %v close fs error: %v", err, errClose)
-		updateLoginMetrics(&user, ipAddr, loginMethod, err)
+		updateLoginMetrics(&user, ipAddr, loginMethod, common.ErrInternalFailure)
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -253,7 +250,8 @@ func (s *webDavServer) authenticate(r *http.Request, ip string) (dataprovider.Us
 	var err error
 	username, password, loginMethod, tlsCert, ok := s.getCredentialsAndLoginMethod(r)
 	if !ok {
-		return user, false, nil, loginMethod, err401
+		user.Username = username
+		return user, false, nil, loginMethod, common.ErrNoCredentials
 	}
 	cachedUser, ok := dataprovider.GetCachedWebDAVUser(username)
 	if ok {
@@ -369,7 +367,7 @@ func writeLog(r *http.Request, err error) {
 
 func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error) {
 	metrics.AddLoginAttempt(loginMethod)
-	if err != nil {
+	if err != nil && err != common.ErrInternalFailure {
 		logger.ConnectionFailedLog(user.Username, ip, loginMethod, common.ProtocolWebDAV, err.Error())
 		event := common.HostEventLoginFailed
 		if _, ok := err.(*dataprovider.RecordNotFoundError); ok {