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
This commit is contained in:
parent
976f588863
commit
43182fc25e
25 changed files with 1633 additions and 427 deletions
12
.github/workflows/development.yml
vendored
12
.github/workflows/development.yml
vendored
|
@ -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/
|
||||
|
|
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
26
go.mod
26
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
|
||||
|
|
52
go.sum
52
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=
|
||||
|
|
|
@ -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
httpd/api_http_user.go
Normal file
238
httpd/api_http_user.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ const (
|
|||
tokenAudienceWebAdmin tokenAudience = "WebAdmin"
|
||||
tokenAudienceWebClient tokenAudience = "WebClient"
|
||||
tokenAudienceAPI tokenAudience = "API"
|
||||
tokenAudienceAPIUser tokenAudience = "APIUser"
|
||||
tokenAudienceCSRF tokenAudience = "CSRF"
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
100
httpd/server.go
100
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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue