Browse Source

WebClient: allow partial download of shared files

each partial download will count as a share usage

Fixes #970

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 3 years ago
parent
commit
7f19f9f39c

+ 53 - 0
internal/httpd/httpd_test.go

@@ -11639,6 +11639,12 @@ func TestShareMaxSessions(t *testing.T) {
 	checkResponseCode(t, http.StatusTooManyRequests, rr)
 	checkResponseCode(t, http.StatusTooManyRequests, rr)
 	assert.Contains(t, rr.Body.String(), "too many open sessions")
 	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)
 	req, err = http.NewRequest(http.MethodGet, sharesPath+"/"+objectID, nil)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
@@ -11833,6 +11839,30 @@ func TestShareReadWrite(t *testing.T) {
 	contentDisposition := rr.Header().Get("Content-Disposition")
 	contentDisposition := rr.Header().Get("Content-Disposition")
 	assert.NotEmpty(t, contentDisposition)
 	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),
 	req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID)+"/"+url.PathEscape("../"+testFileName),
 		bytes.NewBuffer(content))
 		bytes.NewBuffer(content))
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -12137,6 +12167,12 @@ func TestBrowseShares(t *testing.T) {
 	contentDisposition := rr.Header().Get("Content-Disposition")
 	contentDisposition := rr.Header().Get("Content-Disposition")
 	assert.NotEmpty(t, contentDisposition)
 	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)
 	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path="+testFileName), nil)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
@@ -12212,6 +12248,12 @@ func TestBrowseShares(t *testing.T) {
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	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)
 	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "files?path="+testFileName), nil)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
@@ -12250,6 +12292,11 @@ func TestBrowseShares(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 	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 a missing base path
 	share = dataprovider.Share{
 	share = dataprovider.Share{
 		Name:      "test share",
 		Name:      "test share",
@@ -13988,6 +14035,12 @@ func TestWebFilesTransferQuotaLimits(t *testing.T) {
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 	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{
 	share2 := dataprovider.Share{
 		Name:  "share2",
 		Name:  "share2",
 		Scope: dataprovider.ShareScopeWrite,
 		Scope: dataprovider.ShareScopeWrite,

+ 1 - 0
internal/httpd/server.go

@@ -1423,6 +1423,7 @@ func (s *httpdServer) setupWebClientRoutes() {
 		}
 		}
 		// share API exposed to external users
 		// share API exposed to external users
 		s.router.Get(webClientPubSharesPath+"/{id}", s.downloadFromShare)
 		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}/browse", s.handleShareGetFiles)
 		s.router.Get(webClientPubSharesPath+"/{id}/upload", s.handleClientUploadToShare)
 		s.router.Get(webClientPubSharesPath+"/{id}/upload", s.handleClientUploadToShare)
 		s.router.With(compressor.Handler).Get(webClientPubSharesPath+"/{id}/dirs", s.handleShareGetDirContents)
 		s.router.With(compressor.Handler).Get(webClientPubSharesPath+"/{id}/dirs", s.handleShareGetDirContents)

+ 51 - 5
internal/httpd/webclient.go

@@ -36,6 +36,7 @@ import (
 
 
 	"github.com/drakkan/sftpgo/v2/internal/common"
 	"github.com/drakkan/sftpgo/v2/internal/common"
 	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
 	"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/mfa"
 	"github.com/drakkan/sftpgo/v2/internal/smtp"
 	"github.com/drakkan/sftpgo/v2/internal/smtp"
 	"github.com/drakkan/sftpgo/v2/internal/util"
 	"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) {
 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 != "" {
 	if body != "" {
-		errorString = body + " "
+		errorString.WriteString(body)
+		errorString.WriteString(" ")
 	}
 	}
 	if err != nil {
 	if err != nil {
-		errorString += err.Error()
+		errorString.WriteString(err.Error())
 	}
 	}
 	data := clientMessagePage{
 	data := clientMessagePage{
 		baseClientPage: s.getBaseClientPageData(title, "", r),
 		baseClientPage: s.getBaseClientPageData(title, "", r),
-		Error:          errorString,
+		Error:          errorString.String(),
 		Success:        message,
 		Success:        message,
 	}
 	}
 	w.WriteHeader(statusCode)
 	w.WriteHeader(statusCode)
@@ -541,7 +543,7 @@ func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Reque
 		CurrentDir:     url.QueryEscape(dirName),
 		CurrentDir:     url.QueryEscape(dirName),
 		DirsURL:        path.Join(webClientPubSharesPath, share.ShareID, "dirs"),
 		DirsURL:        path.Join(webClientPubSharesPath, share.ShareID, "dirs"),
 		FilesURL:       currentURL,
 		FilesURL:       currentURL,
-		DownloadURL:    path.Join(webClientPubSharesPath, share.ShareID),
+		DownloadURL:    path.Join(webClientPubSharesPath, share.ShareID, "partial"),
 		UploadBaseURL:  path.Join(webClientPubSharesPath, share.ShareID, url.PathEscape(dirName)),
 		UploadBaseURL:  path.Join(webClientPubSharesPath, share.ShareID, url.PathEscape(dirName)),
 		Error:          error,
 		Error:          error,
 		Paths:          getDirMapping(dirName, currentURL),
 		Paths:          getDirMapping(dirName, currentURL),
@@ -656,6 +658,49 @@ func (s *httpdServer) handleWebClientDownloadZip(w http.ResponseWriter, r *http.
 	renderCompressedFiles(w, connection, name, filesList, nil)
 	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) {
 func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite}
 	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["type"] = "2"
 			res["size"] = util.ByteCountIEC(info.Size())
 			res["size"] = util.ByteCountIEC(info.Size())
 		}
 		}
+		res["meta"] = fmt.Sprintf("%v_%v", res["type"], info.Name())
 		res["name"] = info.Name()
 		res["name"] = info.Name()
 		res["url"] = getFileObjectURL(share.GetRelativePath(name), info.Name(),
 		res["url"] = getFileObjectURL(share.GetRelativePath(name), info.Name(),
 			path.Join(webClientPubSharesPath, share.ShareID, "browse"))
 			path.Join(webClientPubSharesPath, share.ShareID, "browse"))

+ 50 - 6
templates/webclient/sharefiles.html

@@ -22,6 +22,13 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
 <link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
 <link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
 <link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
 <link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
 <link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
 <link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/datatables/dataTables.checkboxes.css" rel="stylesheet">
+<style>
+    div.dataTables_wrapper span.selected-info,
+    div.dataTables_wrapper span.selected-item {
+        margin-left: 0.5em;
+    }
+</style>
 {{end}}
 {{end}}
 
 
 {{define "page_body"}}
 {{define "page_body"}}
@@ -42,6 +49,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
             <table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
             <table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
                 <thead>
                 <thead>
                     <tr>
                     <tr>
+                        <th></th>
                         <th>Type</th>
                         <th>Type</th>
                         <th>Name</th>
                         <th>Name</th>
                         <th>Size</th>
                         <th>Size</th>
@@ -93,7 +101,10 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
 <script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
 <script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
 <script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
 <script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
 <script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
 <script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.checkboxes.min.js"></script>
 <script type="text/javascript">
 <script type="text/javascript">
+    var spinnerDone = false;
+
     function shortenData(d, cutoff) {
     function shortenData(d, cutoff) {
         if ( typeof d !== 'string' ) {
         if ( typeof d !== 'string' ) {
 			return d;
 			return d;
@@ -208,6 +219,10 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
         }
         }
     }
     }
 
 
+    function getNameFromMeta(meta) {
+        return meta.split('_').slice(1).join('_');
+    }
+
     $(document).ready(function () {
     $(document).ready(function () {
         $('#spinnerModal').on('shown.bs.modal', function () {
         $('#spinnerModal').on('shown.bs.modal', function () {
             if (spinnerDone){
             if (spinnerDone){
@@ -309,12 +324,20 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
         $.fn.dataTable.ext.buttons.download = {
         $.fn.dataTable.ext.buttons.download = {
             text: '<i class="fas fa-download"></i>',
             text: '<i class="fas fa-download"></i>',
             name: 'download',
             name: 'download',
-            titleAttr: "Download the whole share as zip",
+            titleAttr: "Download zip",
             action: function (e, dt, node, config) {
             action: function (e, dt, node, config) {
+                var filesArray = [];
+                var selected = dt.column(0).checkboxes.selected();
+                for (i = 0; i < selected.length; i++) {
+                    filesArray.push(getNameFromMeta(selected[i]));
+                }
+                var files = encodeURIComponent(JSON.stringify(filesArray));
                 var downloadURL = '{{.DownloadURL}}';
                 var downloadURL = '{{.DownloadURL}}';
+                var currentDir = '{{.CurrentDir}}';
                 var ts = new Date().getTime().toString();
                 var ts = new Date().getTime().toString();
-                window.location = `${downloadURL}?_=${ts}`;
-            }
+                window.location = `${downloadURL}?path=${currentDir}&files=${files}&_=${ts}`;
+            },
+            enabled: false
         };
         };
 
 
         $.fn.dataTable.ext.buttons.addFiles = {
         $.fn.dataTable.ext.buttons.addFiles = {
@@ -365,8 +388,10 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                     data.start = 0;
                     data.start = 0;
                     data.search.search = "";
                     data.search.search = "";
                 }
                 }
+                data.checkboxes = [];
             },
             },
             "columns": [
             "columns": [
+                { "data": "meta" },
                 { "data": "type" },
                 { "data": "type" },
                 {
                 {
                     "data": "name",
                     "data": "name",
@@ -401,11 +426,30 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
             "columnDefs": [
             "columnDefs": [
                 {
                 {
                     "targets": [0],
                     "targets": [0],
+                    "checkboxes": {
+                        "selectCallback": function (nodes, selected) {
+                            var selectedItems = table.column(0).checkboxes.selected().length;
+                            var selectedText = "";
+                            if (selectedItems == 1) {
+                                selectedText = "1 item selected";
+                            } else if (selectedItems > 1) {
+                                selectedText = `${selectedItems} items selected`;
+                            }
+                            table.button('download:name').enable(selectedItems > 0);
+                            $('#dataTable_info').find('span').remove();
+                            $("#dataTable_info").append('<span class="selected-info"><span class="selected-item">' + selectedText + '</span></span>');
+                        }
+                    },
+                    "orderable": false,
+                    "searchable": false
+                },
+                {
+                    "targets": [1],
                     "visible": false,
                     "visible": false,
                     "searchable": false
                     "searchable": false
                 },
                 },
                 {
                 {
-                    "targets": [2, 3],
+                    "targets": [3, 4],
                     "searchable": false
                     "searchable": false
                 }
                 }
             ],
             ],
@@ -425,8 +469,8 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 {{end}}
                 {{end}}
                 table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
                 table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
             },
             },
-            "orderFixed": [0, 'asc'],
-            "order": [1, 'asc']
+            "orderFixed": [1, 'asc'],
+            "order": [2, 'asc']
         });
         });
 
 
         new $.fn.dataTable.FixedHeader(table);
         new $.fn.dataTable.FixedHeader(table);