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
| Type | Name | Size | @@ -93,7 +101,10 @@ along with this program. If not, see
|---|