web client: allow to upload/delete multiple files

This commit is contained in:
Nicola Murino 2021-11-30 18:40:50 +01:00
parent 0f8170c10f
commit 5db31f0fb3
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
6 changed files with 252 additions and 74 deletions

View file

@ -181,11 +181,15 @@ func uploadUserFiles(w http.ResponseWriter, r *http.Request) {
common.Connections.Add(connection)
defer common.Connections.Remove(connection.GetID())
t := newThrottledReader(r.Body, connection.User.UploadBandwidth, connection)
r.Body = t
err = r.ParseMultipartForm(maxMultipartMem)
if err != nil {
connection.RemoveTransfer(t)
sendAPIResponse(w, r, err, "Unable to parse multipart form", http.StatusBadRequest)
return
}
connection.RemoveTransfer(t)
defer r.MultipartForm.RemoveAll() //nolint:errcheck
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,
) int {
uploaded := 0
connection.User.UploadBandwidth = 0
for _, f := range files {
file, err := f.Open()
if err != nil {

View file

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

View file

@ -6,6 +6,8 @@ import (
"os"
"path"
"strings"
"sync/atomic"
"time"
"github.com/drakkan/sftpgo/v2/common"
"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)
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()
}

View file

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

View file

@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"html/template"
"io"
"net/http"
"net/http/httptest"
"net/url"
@ -1810,6 +1811,18 @@ func TestGetFileWriterErrors(t *testing.T) {
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) {
user := dataprovider.User{
BaseUser: sdk.BaseUser{
@ -1857,6 +1870,9 @@ func TestHTTPDFile(t *testing.T) {
assert.Error(t, err)
assert.Error(t, httpdFile.ErrTransfer)
assert.Equal(t, err, httpdFile.ErrTransfer)
httpdFile.SignalClose()
_, err = httpdFile.Write(nil)
assert.ErrorIs(t, err, errTransferAborted)
}
func TestChangeUserPwd(t *testing.T) {

View file

@ -85,7 +85,7 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="uploadFilesModalLabel">
Upload a file
Upload one or more files
</h5>
<button class="close" type="button" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
@ -93,9 +93,10 @@
</div>
<form id="upload_files_form" action="{{.FilesURL}}?path={{.CurrentDir}}" method="POST" enctype="multipart/form-data">
<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 class="modal-footer">
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
<button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Submit</button>
</div>
@ -148,7 +149,7 @@
<span aria-hidden="true">&times;</span>
</button>
</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">
<button class="btn btn-secondary" type="button" data-dismiss="modal">
Cancel
@ -183,6 +184,7 @@
<script src="{{.StaticURL}}/vendor/codemirror/codemirror.js"></script>
<script src="{{.StaticURL}}/vendor/codemirror/meta.js"></script>
<script type="text/javascript">
var spinnerDone = false;
function getIconForFile(filename) {
var extension = filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
@ -295,46 +297,76 @@
function deleteAction() {
var table = $('#dataTable').DataTable();
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');
$.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() {
@ -345,6 +377,11 @@
}
$(document).ready(function () {
$('#spinnerModal').on('shown.bs.modal', function () {
if (spinnerDone){
$('#spinnerModal').modal('hide');
}
});
$("#create_dir_form").submit(function (event) {
event.preventDefault();
$('#createDirModal').modal('hide');
@ -385,44 +422,73 @@
event.preventDefault();
var keepAliveTimer = setInterval(keepAlive, 90000);
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);
$('#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){
@ -505,6 +571,7 @@
name: 'addFiles',
titleAttr: "Upload files",
action: function (e, dt, node, config) {
document.getElementById("files_name").value = null;
$('#uploadFilesModal').modal('show');
},
enabled: true
@ -671,7 +738,7 @@
table.button('rename:name').enable(selectedItems == 1);
{{end}}
{{if .CanDelete}}
table.button('delete:name').enable(selectedItems == 1);
table.button('delete:name').enable(selectedItems > 0);
{{end}}
{{if .CanShare}}
table.button('share:name').enable(selectedItems > 0);