From 4df0ae82ac518b16158c45a1b74ff0715e044178 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Tue, 30 Nov 2021 20:32:10 +0100 Subject: [PATCH] web client: allow downloading of single shared files without compression Fixes #629 --- common/connection.go | 4 +- common/connection_test.go | 3 +- go.mod | 8 +-- go.sum | 16 ++--- httpd/api_shares.go | 34 +++++++++- httpd/httpd_test.go | 106 +++++++++++++++++++++++++++++++- openapi/openapi.yaml | 11 +++- templates/webclient/shares.html | 5 +- 8 files changed, 166 insertions(+), 21 deletions(-) diff --git a/common/connection.go b/common/connection.go index 0cf14a60..5f494b84 100644 --- a/common/connection.go +++ b/common/connection.go @@ -1088,7 +1088,7 @@ func (c *BaseConnection) GetPermissionDeniedError() error { switch c.protocol { case ProtocolSFTP: return sftp.ErrSSHFxPermissionDenied - case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP: + case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolHTTPShare, ProtocolDataRetention: return os.ErrPermission default: return ErrPermissionDenied @@ -1100,7 +1100,7 @@ func (c *BaseConnection) GetNotExistError() error { switch c.protocol { case ProtocolSFTP: return sftp.ErrSSHFxNoSuchFile - case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP: + case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolHTTPShare, ProtocolDataRetention: return os.ErrNotExist default: return ErrNotExist diff --git a/common/connection_test.go b/common/connection_test.go index 80add81c..f96e93d2 100644 --- a/common/connection_test.go +++ b/common/connection_test.go @@ -256,7 +256,8 @@ func TestErrorsMapping(t *testing.T) { err := conn.GetFsError(fs, os.ErrNotExist) if protocol == ProtocolSFTP { assert.ErrorIs(t, err, sftp.ErrSSHFxNoSuchFile) - } else if protocol == ProtocolWebDAV || protocol == ProtocolFTP || protocol == ProtocolHTTP { + } else if protocol == ProtocolWebDAV || protocol == ProtocolFTP || protocol == ProtocolHTTP || + protocol == ProtocolHTTPShare || protocol == ProtocolDataRetention { assert.EqualError(t, err, os.ErrNotExist.Error()) } else { assert.EqualError(t, err, ErrNotExist.Error()) diff --git a/go.mod b/go.mod index 85560cdc..57801611 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ require ( cloud.google.com/go/storage v1.18.2 github.com/Azure/azure-storage-blob-go v0.14.0 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 - github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8 - github.com/aws/aws-sdk-go v1.42.13 + github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 + github.com/aws/aws-sdk-go v1.42.15 github.com/cockroachdb/cockroach-go/v2 v2.2.4 github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b github.com/fclairamb/ftpserverlib v0.16.0 @@ -128,8 +128,8 @@ require ( golang.org/x/text v0.3.7 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 // indirect - gopkg.in/ini.v1 v1.65.0 // indirect + google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12 // indirect + gopkg.in/ini.v1 v1.66.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) diff --git a/go.sum b/go.sum index 2cff2938..116eba7c 100644 --- a/go.sum +++ b/go.sum @@ -126,8 +126,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8 h1:ZtMr6/tt7VU/Ijpyyedn7eUwwsNX1uskEcR+maLEF18= -github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8/go.mod h1:Kmn5t2Rb93Q4NTprN4+CCgARGvigKMJyxP0WckpTUp0= +github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 h1:loy0fjI90vF44BPW4ZYOkE3tDkGTy7yHURusOJimt+I= +github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387/go.mod h1:GuR5j/NW7AU7tDAQUDGCtpiPxWIOy/c3kiRDnlwiCHc= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= @@ -137,8 +137,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= -github.com/aws/aws-sdk-go v1.42.13 h1:+Nx87T+Bjiq2XybxK6vI98cTEBPLE/hILuZyEenlyEg= -github.com/aws/aws-sdk-go v1.42.13/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= +github.com/aws/aws-sdk-go v1.42.15 h1:RcUChuF7KzrrTqx9LAzJbLBX00LkUY7cH9T1VdxNdqk= +github.com/aws/aws-sdk-go v1.42.15/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4= github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY= @@ -1191,8 +1191,8 @@ google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEc google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 h1:b9mVrqYfq3P4bCdaLg1qtBnPzUYgglsIdjZkL/fQVOE= -google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12 h1:DN5b3HU13J4sMd/QjDx34U6afpaexKTDdop+26pdjdk= +google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= @@ -1250,8 +1250,8 @@ gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWd gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.63.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.65.0 h1:B2//IEITFk89S+Nl2tozBeqUvFEpUAY6daarSlrx8jU= -gopkg.in/ini.v1 v1.65.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.66.0 h1:tYFFjdYXTsNBxJhYBABRbTuaKkX6UBzOvbYwhEcaZJQ= +gopkg.in/ini.v1 v1.66.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= diff --git a/httpd/api_shares.go b/httpd/api_shares.go index 154936c9..18f2749e 100644 --- a/httpd/api_shares.go +++ b/httpd/api_shares.go @@ -1,9 +1,11 @@ package httpd import ( + "context" "errors" "fmt" "net/http" + "os" "github.com/go-chi/render" "github.com/rs/xid" @@ -141,9 +143,37 @@ func downloadFromShare(w http.ResponseWriter, r *http.Request) { common.Connections.Add(connection) defer common.Connections.Remove(connection.GetID()) + compress := true + var info os.FileInfo + if len(share.Paths) > 0 && r.URL.Query().Get("compress") == "false" { + info, err = connection.Stat(share.Paths[0], 0) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + if !info.IsDir() { + compress = false + } + } + dataprovider.UpdateShareLastUse(&share, 1) //nolint:errcheck - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"share-%v.zip\"", share.ShareID)) - renderCompressedFiles(w, connection, "/", share.Paths, &share) + if compress { + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"share-%v.zip\"", share.ShareID)) + renderCompressedFiles(w, connection, "/", share.Paths, &share) + return + } + if status, err := downloadFile(w, r, connection, share.Paths[0], info, false); err != nil { + dataprovider.UpdateShareLastUse(&share, -1) //nolint:errcheck + 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 uploadToShare(w http.ResponseWriter, r *http.Request) { diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 65c2de6b..58b120cc 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -8867,7 +8867,7 @@ func TestShareUsage(t *testing.T) { req.Header.Add("Content-Type", writer.FormDataContentType()) req.SetBasicAuth(defaultUsername, defaultPassword) rr = executeRequest(req) - checkResponseCode(t, http.StatusInternalServerError, rr) + checkResponseCode(t, http.StatusForbidden, rr) assert.Contains(t, rr.Body.String(), "permission denied") body = new(bytes.Buffer) @@ -8913,6 +8913,110 @@ func TestShareUsage(t *testing.T) { executeRequest(req) } +func TestShareUncompressed(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + testFileName := "testfile.dat" + testFileSize := int64(65536) + testFilePath := filepath.Join(user.GetHomeDir(), testFileName) + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + + token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + share := dataprovider.Share{ + Name: "test share", + Scope: dataprovider.ShareScopeRead, + Paths: []string{"/"}, + Password: defaultPassword, + MaxTokens: 0, + } + asJSON, err := json.Marshal(share) + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + objectID := rr.Header().Get("X-Object-ID") + assert.NotEmpty(t, objectID) + + req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID, nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Equal(t, "application/zip", rr.Header().Get("Content-Type")) + + req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID+"?compress=false", nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Equal(t, "application/zip", rr.Header().Get("Content-Type")) + + share = dataprovider.Share{ + Name: "test share1", + Scope: dataprovider.ShareScopeRead, + Paths: []string{testFileName}, + Password: defaultPassword, + MaxTokens: 0, + } + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + objectID = rr.Header().Get("X-Object-ID") + assert.NotEmpty(t, objectID) + + req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID, nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Equal(t, "application/zip", rr.Header().Get("Content-Type")) + + req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID+"?compress=false", nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type")) + + user.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermUpload} + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID+"?compress=false", nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + user.Permissions["/"] = []string{dataprovider.PermAny} + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + + err = os.Remove(testFilePath) + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID+"?compress=false", nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestUserAPIShareErrors(t *testing.T) { user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index fb90f309..cd9a2f50 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -22,7 +22,7 @@ info: 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. SFTPGo allows to create HTTP/S links to externally share files and folders securely, by setting limits to the number of downloads/uploads, protecting the share with a password, limiting access by source IP address, setting an automatic expiration date. - version: 2.2.0 + version: 2.2.0-dev contact: name: API support url: 'https://github.com/drakkan/sftpgo' @@ -69,11 +69,18 @@ paths: summary: Download shared files and folders as a single zip file description: A zip file, containing the shared 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. The share must be defined with the read scope and the associated user must have list and download permissions operationId: get_share + parameters: + - in: query + name: compress + schema: + type: boolean + default: true + required: false responses: '200': description: successful operation content: - 'application/zip': + '*/*': schema: type: string format: binary diff --git a/templates/webclient/shares.html b/templates/webclient/shares.html index c6a870f2..ce9d5b46 100644 --- a/templates/webclient/shares.html +++ b/templates/webclient/shares.html @@ -92,7 +92,8 @@