Browse Source

web client: allow to upload/delete multiple files

Nicola Murino 3 years ago
parent
commit
5db31f0fb3
6 changed files with 252 additions and 74 deletions
  1. 5 0
      httpd/api_http_user.go
  2. 4 0
      httpd/api_shares.go
  3. 86 0
      httpd/handler.go
  4. 1 1
      httpd/httpd_test.go
  5. 16 0
      httpd/internal_test.go
  6. 140 73
      templates/webclient/files.html

+ 5 - 0
httpd/api_http_user.go

@@ -181,11 +181,15 @@ func uploadUserFiles(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
+	t := newThrottledReader(r.Body, connection.User.UploadBandwidth, connection)
+	r.Body = t
 	err = r.ParseMultipartForm(maxMultipartMem)
 	err = r.ParseMultipartForm(maxMultipartMem)
 	if err != nil {
 	if err != nil {
+		connection.RemoveTransfer(t)
 		sendAPIResponse(w, r, err, "Unable to parse multipart form", http.StatusBadRequest)
 		sendAPIResponse(w, r, err, "Unable to parse multipart form", http.StatusBadRequest)
 		return
 		return
 	}
 	}
+	connection.RemoveTransfer(t)
 	defer r.MultipartForm.RemoveAll() //nolint:errcheck
 	defer r.MultipartForm.RemoveAll() //nolint:errcheck
 
 
 	parentDir := util.CleanPath(r.URL.Query().Get("path"))
 	parentDir := util.CleanPath(r.URL.Query().Get("path"))
@@ -201,6 +205,7 @@ func doUploadFiles(w http.ResponseWriter, r *http.Request, connection *Connectio
 	files []*multipart.FileHeader,
 	files []*multipart.FileHeader,
 ) int {
 ) int {
 	uploaded := 0
 	uploaded := 0
+	connection.User.UploadBandwidth = 0
 	for _, f := range files {
 	for _, f := range files {
 		file, err := f.Open()
 		file, err := f.Open()
 		if err != nil {
 		if err != nil {

+ 4 - 0
httpd/api_shares.go

@@ -158,11 +158,15 @@ func uploadToShare(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
+	t := newThrottledReader(r.Body, connection.User.UploadBandwidth, connection)
+	r.Body = t
 	err = r.ParseMultipartForm(maxMultipartMem)
 	err = r.ParseMultipartForm(maxMultipartMem)
 	if err != nil {
 	if err != nil {
+		connection.RemoveTransfer(t)
 		sendAPIResponse(w, r, err, "Unable to parse multipart form", http.StatusBadRequest)
 		sendAPIResponse(w, r, err, "Unable to parse multipart form", http.StatusBadRequest)
 		return
 		return
 	}
 	}
+	connection.RemoveTransfer(t)
 	defer r.MultipartForm.RemoveAll() //nolint:errcheck
 	defer r.MultipartForm.RemoveAll() //nolint:errcheck
 
 
 	files := r.MultipartForm.File["filenames"]
 	files := r.MultipartForm.File["filenames"]

+ 86 - 0
httpd/handler.go

@@ -6,6 +6,8 @@ import (
 	"os"
 	"os"
 	"path"
 	"path"
 	"strings"
 	"strings"
+	"sync/atomic"
+	"time"
 
 
 	"github.com/drakkan/sftpgo/v2/common"
 	"github.com/drakkan/sftpgo/v2/common"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
@@ -214,3 +216,87 @@ func (c *Connection) handleUploadFile(fs vfs.Fs, resolvedPath, filePath, request
 		common.TransferUpload, 0, initialSize, maxWriteSize, isNewFile, fs)
 		common.TransferUpload, 0, initialSize, maxWriteSize, isNewFile, fs)
 	return newHTTPDFile(baseTransfer, w, nil), nil
 	return newHTTPDFile(baseTransfer, w, nil), nil
 }
 }
+
+func newThrottledReader(r io.ReadCloser, limit int64, conn *Connection) *throttledReader {
+	t := &throttledReader{
+		bytesRead:     0,
+		id:            conn.GetTransferID(),
+		limit:         limit,
+		r:             r,
+		abortTransfer: 0,
+		start:         time.Now(),
+		conn:          conn,
+	}
+	conn.AddTransfer(t)
+	return t
+}
+
+type throttledReader struct {
+	bytesRead     int64
+	id            uint64
+	limit         int64
+	r             io.ReadCloser
+	abortTransfer int32
+	start         time.Time
+	conn          *Connection
+}
+
+func (t *throttledReader) GetID() uint64 {
+	return t.id
+}
+
+func (t *throttledReader) GetType() int {
+	return common.TransferUpload
+}
+
+func (t *throttledReader) GetSize() int64 {
+	return atomic.LoadInt64(&t.bytesRead)
+}
+
+func (t *throttledReader) GetVirtualPath() string {
+	return "**reading request body**"
+}
+
+func (t *throttledReader) GetStartTime() time.Time {
+	return t.start
+}
+
+func (t *throttledReader) SignalClose() {
+	atomic.StoreInt32(&(t.abortTransfer), 1)
+}
+
+func (t *throttledReader) Truncate(fsPath string, size int64) (int64, error) {
+	return 0, vfs.ErrVfsUnsupported
+}
+
+func (t *throttledReader) GetRealFsPath(fsPath string) string {
+	return ""
+}
+
+func (t *throttledReader) SetTimes(fsPath string, atime time.Time, mtime time.Time) bool {
+	return false
+}
+
+func (t *throttledReader) Read(p []byte) (n int, err error) {
+	if atomic.LoadInt32(&t.abortTransfer) == 1 {
+		return 0, errTransferAborted
+	}
+
+	t.conn.UpdateLastActivity()
+	n, err = t.r.Read(p)
+	if t.limit > 0 {
+		atomic.AddInt64(&t.bytesRead, int64(n))
+		trasferredBytes := atomic.LoadInt64(&t.bytesRead)
+		elapsed := time.Since(t.start).Nanoseconds() / 1000000
+		wantedElapsed := 1000 * (trasferredBytes / 1024) / t.limit
+		if wantedElapsed > elapsed {
+			toSleep := time.Duration(wantedElapsed - elapsed)
+			time.Sleep(toSleep * time.Millisecond)
+		}
+	}
+	return
+}
+
+func (t *throttledReader) Close() error {
+	return t.r.Close()
+}

+ 1 - 1
httpd/httpd_test.go

@@ -10668,7 +10668,7 @@ func TestClientUserClose(t *testing.T) {
 		req.Header.Add("Content-Type", writer.FormDataContentType())
 		req.Header.Add("Content-Type", writer.FormDataContentType())
 		setBearerForReq(req, webAPIToken)
 		setBearerForReq(req, webAPIToken)
 		rr := executeRequest(req)
 		rr := executeRequest(req)
-		checkResponseCode(t, http.StatusInternalServerError, rr)
+		checkResponseCode(t, http.StatusBadRequest, rr)
 		assert.Contains(t, rr.Body.String(), "transfer aborted")
 		assert.Contains(t, rr.Body.String(), "transfer aborted")
 	}()
 	}()
 	// wait for the transfers
 	// wait for the transfers

+ 16 - 0
httpd/internal_test.go

@@ -9,6 +9,7 @@ import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"html/template"
 	"html/template"
+	"io"
 	"net/http"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptest"
 	"net/url"
 	"net/url"
@@ -1810,6 +1811,18 @@ func TestGetFileWriterErrors(t *testing.T) {
 	assert.Error(t, err)
 	assert.Error(t, err)
 }
 }
 
 
+func TestThrottledHandler(t *testing.T) {
+	tr := &throttledReader{
+		r: io.NopCloser(bytes.NewBuffer(nil)),
+	}
+	err := tr.Close()
+	assert.NoError(t, err)
+	assert.Empty(t, tr.GetRealFsPath("real path"))
+	assert.False(t, tr.SetTimes("p", time.Now(), time.Now()))
+	_, err = tr.Truncate("", 0)
+	assert.ErrorIs(t, err, vfs.ErrVfsUnsupported)
+}
+
 func TestHTTPDFile(t *testing.T) {
 func TestHTTPDFile(t *testing.T) {
 	user := dataprovider.User{
 	user := dataprovider.User{
 		BaseUser: sdk.BaseUser{
 		BaseUser: sdk.BaseUser{
@@ -1857,6 +1870,9 @@ func TestHTTPDFile(t *testing.T) {
 	assert.Error(t, err)
 	assert.Error(t, err)
 	assert.Error(t, httpdFile.ErrTransfer)
 	assert.Error(t, httpdFile.ErrTransfer)
 	assert.Equal(t, err, httpdFile.ErrTransfer)
 	assert.Equal(t, err, httpdFile.ErrTransfer)
+	httpdFile.SignalClose()
+	_, err = httpdFile.Write(nil)
+	assert.ErrorIs(t, err, errTransferAborted)
 }
 }
 
 
 func TestChangeUserPwd(t *testing.T) {
 func TestChangeUserPwd(t *testing.T) {

+ 140 - 73
templates/webclient/files.html

@@ -85,7 +85,7 @@
         <div class="modal-content">
         <div class="modal-content">
             <div class="modal-header">
             <div class="modal-header">
                 <h5 class="modal-title" id="uploadFilesModalLabel">
                 <h5 class="modal-title" id="uploadFilesModalLabel">
-                    Upload a file
+                    Upload one or more files
                 </h5>
                 </h5>
                 <button class="close" type="button" data-dismiss="modal" aria-label="Close">
                 <button class="close" type="button" data-dismiss="modal" aria-label="Close">
                     <span aria-hidden="true">&times;</span>
                     <span aria-hidden="true">&times;</span>
@@ -93,9 +93,10 @@
             </div>
             </div>
             <form id="upload_files_form" action="{{.FilesURL}}?path={{.CurrentDir}}" method="POST" enctype="multipart/form-data">
             <form id="upload_files_form" action="{{.FilesURL}}?path={{.CurrentDir}}" method="POST" enctype="multipart/form-data">
                  <div class="modal-body">
                  <div class="modal-body">
-                    <input type="file" class="form-control-file" id="files_name" name="filenames" required>
+                    <input type="file" class="form-control-file" id="files_name" name="filenames" required multiple>
                 </div>
                 </div>
                 <div class="modal-footer">
                 <div class="modal-footer">
+                    <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
                     <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
                     <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
                     <button type="submit" class="btn btn-primary">Submit</button>
                     <button type="submit" class="btn btn-primary">Submit</button>
                 </div>
                 </div>
@@ -148,7 +149,7 @@
                     <span aria-hidden="true">&times;</span>
                     <span aria-hidden="true">&times;</span>
                 </button>
                 </button>
             </div>
             </div>
-            <div class="modal-body">Do you want to delete the selected item?</div>
+            <div class="modal-body">Do you want to delete the selected item/s?</div>
             <div class="modal-footer">
             <div class="modal-footer">
                 <button class="btn btn-secondary" type="button" data-dismiss="modal">
                 <button class="btn btn-secondary" type="button" data-dismiss="modal">
                     Cancel
                     Cancel
@@ -183,6 +184,7 @@
 <script src="{{.StaticURL}}/vendor/codemirror/codemirror.js"></script>
 <script src="{{.StaticURL}}/vendor/codemirror/codemirror.js"></script>
 <script src="{{.StaticURL}}/vendor/codemirror/meta.js"></script>
 <script src="{{.StaticURL}}/vendor/codemirror/meta.js"></script>
 <script type="text/javascript">
 <script type="text/javascript">
+    var spinnerDone = false;
 
 
     function getIconForFile(filename) {
     function getIconForFile(filename) {
         var extension = filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
         var extension = filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
@@ -295,46 +297,76 @@
     function deleteAction() {
     function deleteAction() {
         var table = $('#dataTable').DataTable();
         var table = $('#dataTable').DataTable();
         table.button('delete:name').enable(false);
         table.button('delete:name').enable(false);
-        var selected = table.column(0).checkboxes.selected()[0];
-        var itemType = getTypeFromMeta(selected);
-        var itemName = getNameFromMeta(selected);
-        var path;
-        if (itemType == "1"){
-            path = '{{.DirsURL}}';
-        } else {
-            path = '{{.FilesURL}}';
-        }
-        path+='?path={{.CurrentDir}}'+fixedEncodeURIComponent("/"+itemName);
+
+        var selectedItems = table.column(0).checkboxes.selected()
+        var has_errors = false;
+        var index = 0;
+        var success = 0;
+        spinnerDone = false;
+
         $('#deleteModal').modal('hide');
         $('#deleteModal').modal('hide');
-        $.ajax({
-            url: path,
-            type: 'DELETE',
-            dataType: 'json',
-            headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
-            timeout: 15000,
-            success: function (result) {
-                location.reload();
-            },
-            error: function ($xhr, textStatus, errorThrown) {
-                var txt = "Unable to delete the selected item";
-                if ($xhr) {
-                    var json = $xhr.responseJSON;
-                    if (json) {
-                        if (json.message) {
-                            txt = json.message;
-                        }
-                        if (json.error) {
-                            txt += ": " + json.error;
+        $('#spinnerModal').modal('show');
+
+        function deleteItem() {
+            if (index >= selectedItems.length || has_errors){
+                $('#spinnerModal').modal('hide');
+                spinnerDone = true;
+                if (!has_errors){
+                    location.reload();
+                }
+                return;
+            }
+            var selected = selectedItems[index];
+            var itemType = getTypeFromMeta(selected);
+            var itemName = getNameFromMeta(selected);
+            var path;
+            if (itemType == "1"){
+                path = '{{.DirsURL}}';
+            } else {
+                path = '{{.FilesURL}}';
+            }
+            path+='?path={{.CurrentDir}}'+fixedEncodeURIComponent("/"+itemName);
+
+            $.ajax({
+                url: path,
+                type: 'DELETE',
+                dataType: 'json',
+                headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
+                timeout: 60000,
+                success: function (result) {
+                    index++;
+                    success++;
+                    deleteItem();
+                },
+                error: function ($xhr, textStatus, errorThrown) {
+                    index++;
+                    has_errors = true;
+                    var txt = "Unable to delete the selected item/s";
+                    if (success > 0){
+                        txt = "Not all the selected items have been deleted, please reload the page";
+                    }
+                    if ($xhr) {
+                        var json = $xhr.responseJSON;
+                        if (json) {
+                            if (json.message) {
+                                txt = json.message;
+                            }
+                            if (json.error) {
+                                txt += ": " + json.error;
+                            }
                         }
                         }
                     }
                     }
+                    $('#errorTxt').text(txt);
+                    $('#errorMsg').show();
+                    setTimeout(function () {
+                        $('#errorMsg').hide();
+                    }, 10000);
+                    deleteItem();
                 }
                 }
-                $('#errorTxt').text(txt);
-                $('#errorMsg').show();
-                setTimeout(function () {
-                    $('#errorMsg').hide();
-                }, 5000);
-            }
-        });
+            });
+        }
+
+        deleteItem();
     }
     }
 
 
     function keepAlive() {
     function keepAlive() {
@@ -345,6 +377,11 @@
     }
     }
 
 
     $(document).ready(function () {
     $(document).ready(function () {
+        $('#spinnerModal').on('shown.bs.modal', function () {
+            if (spinnerDone){
+                $('#spinnerModal').modal('hide');
+            }
+        });
         $("#create_dir_form").submit(function (event) {
         $("#create_dir_form").submit(function (event) {
             event.preventDefault();
             event.preventDefault();
             $('#createDirModal').modal('hide');
             $('#createDirModal').modal('hide');
@@ -385,44 +422,73 @@
             event.preventDefault();
             event.preventDefault();
             var keepAliveTimer = setInterval(keepAlive, 90000);
             var keepAliveTimer = setInterval(keepAlive, 90000);
             var path = '{{.FilesURL}}?path={{.CurrentDir}}';
             var path = '{{.FilesURL}}?path={{.CurrentDir}}';
-            $.ajax({
-                url: path,
-                type: 'POST',
-                data: new FormData(this),
-                processData: false,
-                contentType: false,
-                headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
-                timeout: 0,
-                beforeSend: function () {
-                    $('#uploadFilesModal').modal('hide');
-                    $('#spinnerModal').modal('show');
-                },
-                success: function (result) {
-                    clearInterval(keepAliveTimer);
-                    location.reload();
-                },
-                error: function ($xhr, textStatus, errorThrown) {
+
+            var files = $("#files_name")[0].files;
+            var has_errors = false;
+            var index = 0;
+            var success = 0;
+            spinnerDone = false;
+
+            $('#uploadFilesModal').modal('hide');
+            $('#spinnerModal').modal('show');
+
+            function uploadFile() {
+                if (index >= files.length || has_errors){
+                    //console.log("upload done, index: "+index+" has errors: "+has_errors+" ok: "+success);
                     clearInterval(keepAliveTimer);
                     clearInterval(keepAliveTimer);
                     $('#spinnerModal').modal('hide');
                     $('#spinnerModal').modal('hide');
-                    var txt = "Error uploading files";
-                    if ($xhr) {
-                        var json = $xhr.responseJSON;
-                        if (json) {
-                            if (json.message) {
-                                txt = json.message;
-                            }
-                            if (json.error) {
-                                txt += ": " + json.error;
+                    spinnerDone = true;
+                    if (!has_errors){
+                        location.reload();
+                    }
+                    return;
+                }
+                //console.log("upload file, index: "+index);
+                var data = new FormData();
+                data.append('filenames', files[index]);
+
+                $.ajax({
+                    url: path,
+                    type: 'POST',
+                    data: data,
+                    processData: false,
+                    contentType: false,
+                    headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
+                    timeout: 0,
+                    success: function (result) {
+                        index++;
+                        success++;
+                        uploadFile();
+                    },
+                    error: function ($xhr, textStatus, errorThrown) {
+                        index++;
+                        has_errors = true;
+                        var txt = "Error uploading files";
+                        if (success > 0){
+                            txt = "Not all files have been uploaded, please reload the page";
+                        }
+                        if ($xhr) {
+                            var json = $xhr.responseJSON;
+                            if (json) {
+                                if (json.message) {
+                                    txt = json.message;
+                                }
+                                if (json.error) {
+                                    txt += ": " + json.error;
+                                }
                             }
                             }
                         }
                         }
+                        $('#errorTxt').text(txt);
+                        $('#errorMsg').show();
+                        setTimeout(function () {
+                            $('#errorMsg').hide();
+                        }, 10000);
+                        uploadFile();
                     }
                     }
-                    $('#errorTxt').text(txt);
-                    $('#errorMsg').show();
-                    setTimeout(function () {
-                        $('#errorMsg').hide();
-                    }, 5000);
-                }
-            });
+                });
+            }
+
+            uploadFile();
         });
         });
 
 
         $("#rename_form").submit(function (event){
         $("#rename_form").submit(function (event){
@@ -505,6 +571,7 @@
             name: 'addFiles',
             name: 'addFiles',
             titleAttr: "Upload files",
             titleAttr: "Upload files",
             action: function (e, dt, node, config) {
             action: function (e, dt, node, config) {
+                document.getElementById("files_name").value = null;
                 $('#uploadFilesModal').modal('show');
                 $('#uploadFilesModal').modal('show');
             },
             },
             enabled: true
             enabled: true
@@ -671,7 +738,7 @@
                             table.button('rename:name').enable(selectedItems == 1);
                             table.button('rename:name').enable(selectedItems == 1);
                             {{end}}
                             {{end}}
                             {{if .CanDelete}}
                             {{if .CanDelete}}
-                            table.button('delete:name').enable(selectedItems == 1);
+                            table.button('delete:name').enable(selectedItems > 0);
                             {{end}}
                             {{end}}
                             {{if .CanShare}}
                             {{if .CanShare}}
                             table.button('share:name').enable(selectedItems > 0);
                             table.button('share:name').enable(selectedItems > 0);