From 7f19f9f39c8094493b7c899f5ac5845aeda1acd6 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Mon, 19 Sep 2022 19:58:35 +0200 Subject: [PATCH] WebClient: allow partial download of shared files each partial download will count as a share usage Fixes #970 Signed-off-by: Nicola Murino --- internal/httpd/httpd_test.go | 53 +++++++++++++++++++++++++++ internal/httpd/server.go | 1 + internal/httpd/webclient.go | 56 ++++++++++++++++++++++++++--- templates/webclient/sharefiles.html | 56 +++++++++++++++++++++++++---- 4 files changed, 155 insertions(+), 11 deletions(-) diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index f01523c9..856685dc 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -11639,6 +11639,12 @@ func TestShareMaxSessions(t *testing.T) { checkResponseCode(t, http.StatusTooManyRequests, rr) assert.Contains(t, rr.Body.String(), "too many open sessions") + req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID+"/partial", nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusTooManyRequests, rr) + assert.Contains(t, rr.Body.String(), "too many open sessions") + req, err = http.NewRequest(http.MethodGet, sharesPath+"/"+objectID, nil) assert.NoError(t, err) rr = executeRequest(req) @@ -11833,6 +11839,30 @@ func TestShareReadWrite(t *testing.T) { contentDisposition := rr.Header().Get("Content-Disposition") assert.NotEmpty(t, contentDisposition) + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "partial?files="+ + url.QueryEscape(fmt.Sprintf(`["%v"]`, testFileName))), nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + contentDisposition = rr.Header().Get("Content-Disposition") + assert.NotEmpty(t, contentDisposition) + assert.Equal(t, "application/zip", rr.Header().Get("Content-Type")) + // invalid files list + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "partial?files="+testFileName), nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + assert.Contains(t, rr.Body.String(), "Unable to get files list") + // missing directory + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "partial?path=missing"), nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + assert.Contains(t, rr.Body.String(), "Unable to get files list") + req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID)+"/"+url.PathEscape("../"+testFileName), bytes.NewBuffer(content)) assert.NoError(t, err) @@ -12137,6 +12167,12 @@ func TestBrowseShares(t *testing.T) { contentDisposition := rr.Header().Get("Content-Disposition") assert.NotEmpty(t, contentDisposition) + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "partial?path=%2F.."), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "Invalid share path") + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path="+testFileName), nil) assert.NoError(t, err) rr = executeRequest(req) @@ -12212,6 +12248,12 @@ func TestBrowseShares(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "partial"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "Unable to validate share") + req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path="+testFileName), nil) assert.NoError(t, err) rr = executeRequest(req) @@ -12250,6 +12292,11 @@ func TestBrowseShares(t *testing.T) { assert.NoError(t, err) rr = executeRequest(req) checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "partial?path=%2F"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) // share a missing base path share = dataprovider.Share{ Name: "test share", @@ -13988,6 +14035,12 @@ func TestWebFilesTransferQuotaLimits(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusForbidden, rr) + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "/partial"), nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Denying share read due to quota limits") + share2 := dataprovider.Share{ Name: "share2", Scope: dataprovider.ShareScopeWrite, diff --git a/internal/httpd/server.go b/internal/httpd/server.go index 990c4274..888a0537 100644 --- a/internal/httpd/server.go +++ b/internal/httpd/server.go @@ -1423,6 +1423,7 @@ func (s *httpdServer) setupWebClientRoutes() { } // share API exposed to external users s.router.Get(webClientPubSharesPath+"/{id}", s.downloadFromShare) + s.router.Get(webClientPubSharesPath+"/{id}/partial", s.handleClientSharePartialDownload) s.router.Get(webClientPubSharesPath+"/{id}/browse", s.handleShareGetFiles) s.router.Get(webClientPubSharesPath+"/{id}/upload", s.handleClientUploadToShare) s.router.With(compressor.Handler).Get(webClientPubSharesPath+"/{id}/dirs", s.handleShareGetDirContents) diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go index 6743869a..263f95e6 100644 --- a/internal/httpd/webclient.go +++ b/internal/httpd/webclient.go @@ -36,6 +36,7 @@ import ( "github.com/drakkan/sftpgo/v2/internal/common" "github.com/drakkan/sftpgo/v2/internal/dataprovider" + "github.com/drakkan/sftpgo/v2/internal/logger" "github.com/drakkan/sftpgo/v2/internal/mfa" "github.com/drakkan/sftpgo/v2/internal/smtp" "github.com/drakkan/sftpgo/v2/internal/util" @@ -403,16 +404,17 @@ func renderClientTemplate(w http.ResponseWriter, tmplName string, data any) { } func (s *httpdServer) renderClientMessagePage(w http.ResponseWriter, r *http.Request, title, body string, statusCode int, err error, message string) { - var errorString string + var errorString strings.Builder if body != "" { - errorString = body + " " + errorString.WriteString(body) + errorString.WriteString(" ") } if err != nil { - errorString += err.Error() + errorString.WriteString(err.Error()) } data := clientMessagePage{ baseClientPage: s.getBaseClientPageData(title, "", r), - Error: errorString, + Error: errorString.String(), Success: message, } w.WriteHeader(statusCode) @@ -541,7 +543,7 @@ func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Reque CurrentDir: url.QueryEscape(dirName), DirsURL: path.Join(webClientPubSharesPath, share.ShareID, "dirs"), FilesURL: currentURL, - DownloadURL: path.Join(webClientPubSharesPath, share.ShareID), + DownloadURL: path.Join(webClientPubSharesPath, share.ShareID, "partial"), UploadBaseURL: path.Join(webClientPubSharesPath, share.ShareID, url.PathEscape(dirName)), Error: error, Paths: getDirMapping(dirName, currentURL), @@ -656,6 +658,49 @@ func (s *httpdServer) handleWebClientDownloadZip(w http.ResponseWriter, r *http. renderCompressedFiles(w, connection, name, filesList, nil) } +func (s *httpdServer) handleClientSharePartialDownload(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite} + share, connection, err := s.checkPublicShare(w, r, validScopes, true) + if err != nil { + return + } + if err := validateBrowsableShare(share, connection); err != nil { + s.renderClientMessagePage(w, r, "Unable to validate share", "", getRespStatus(err), err, "") + return + } + name, err := getBrowsableSharedPath(share, r) + if err != nil { + s.renderClientMessagePage(w, r, "Invalid share path", "", getRespStatus(err), err, "") + return + } + if err = common.Connections.Add(connection); err != nil { + s.renderClientMessagePage(w, r, "Unable to add connection", "", http.StatusTooManyRequests, err, "") + return + } + defer common.Connections.Remove(connection.GetID()) + + transferQuota := connection.GetTransferQuota() + if !transferQuota.HasDownloadSpace() { + err = connection.GetReadQuotaExceededError() + connection.Log(logger.LevelInfo, "denying share read due to quota limits") + s.renderClientMessagePage(w, r, "Denying share read due to quota limits", "", getMappedStatusCode(err), err, "") + return + } + files := r.URL.Query().Get("files") + var filesList []string + err = json.Unmarshal([]byte(files), &filesList) + if err != nil { + s.renderClientMessagePage(w, r, "Unable to get files list", "", http.StatusInternalServerError, err, "") + return + } + + dataprovider.UpdateShareLastUse(&share, 1) //nolint:errcheck + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", + getCompressedFileName(fmt.Sprintf("share-%s", share.Name), filesList))) + renderCompressedFiles(w, connection, name, filesList, &share) +} + func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite} @@ -696,6 +741,7 @@ func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.R res["type"] = "2" res["size"] = util.ByteCountIEC(info.Size()) } + res["meta"] = fmt.Sprintf("%v_%v", res["type"], info.Name()) res["name"] = info.Name() res["url"] = getFileObjectURL(share.GetRelativePath(name), info.Name(), path.Join(webClientPubSharesPath, share.ShareID, "browse")) diff --git a/templates/webclient/sharefiles.html b/templates/webclient/sharefiles.html index 1370dfdb..72014ee9 100644 --- a/templates/webclient/sharefiles.html +++ b/templates/webclient/sharefiles.html @@ -22,6 +22,13 @@ along with this program. If not, see . + + {{end}} {{define "page_body"}} @@ -42,6 +49,7 @@ along with this program. If not, see . + @@ -93,7 +101,10 @@ along with this program. If not, see . +
Type Name Size