mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-25 09:00:27 +00:00
WebClient: allow partial download of shared files
each partial download will count as a share usage Fixes #970 Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
parent
f19691250d
commit
7f19f9f39c
4 changed files with 155 additions and 11 deletions
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -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/fixedHeader.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}}
|
||||
|
||||
{{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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Type</th>
|
||||
<th>Name</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.responsive.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">
|
||||
var spinnerDone = false;
|
||||
|
||||
function shortenData(d, cutoff) {
|
||||
if ( typeof d !== 'string' ) {
|
||||
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 () {
|
||||
$('#spinnerModal').on('shown.bs.modal', function () {
|
||||
if (spinnerDone){
|
||||
|
@ -309,12 +324,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||
$.fn.dataTable.ext.buttons.download = {
|
||||
text: '<i class="fas fa-download"></i>',
|
||||
name: 'download',
|
||||
titleAttr: "Download the whole share as zip",
|
||||
titleAttr: "Download zip",
|
||||
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 currentDir = '{{.CurrentDir}}';
|
||||
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 = {
|
||||
|
@ -365,8 +388,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||
data.start = 0;
|
||||
data.search.search = "";
|
||||
}
|
||||
data.checkboxes = [];
|
||||
},
|
||||
"columns": [
|
||||
{ "data": "meta" },
|
||||
{ "data": "type" },
|
||||
{
|
||||
"data": "name",
|
||||
|
@ -401,11 +426,30 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||
"columnDefs": [
|
||||
{
|
||||
"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,
|
||||
"searchable": false
|
||||
},
|
||||
{
|
||||
"targets": [2, 3],
|
||||
"targets": [3, 4],
|
||||
"searchable": false
|
||||
}
|
||||
],
|
||||
|
@ -425,8 +469,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||
{{end}}
|
||||
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);
|
||||
|
|
Loading…
Reference in a new issue