mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-21 23:20:24 +00:00
web client: allow to upload/delete multiple files
This commit is contained in:
parent
0f8170c10f
commit
5db31f0fb3
6 changed files with 252 additions and 74 deletions
|
@ -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 {
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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">×</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">×</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);
|
||||
|
|
Loading…
Reference in a new issue