web client: use fetch to upload files

also add REST API to upload a single file as POST body
This commit is contained in:
Nicola Murino 2021-12-08 19:25:22 +01:00
parent 5b4ef0ee3b
commit c153330ab8
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
19 changed files with 851 additions and 161 deletions

View file

@ -288,7 +288,7 @@ func (t *BaseTransfer) updateTimes() {
func (t *BaseTransfer) updateQuota(numFiles int, fileSize int64) bool {
// S3 uploads are atomic, if there is an error nothing is uploaded
if t.File == nil && t.ErrTransfer != nil {
if t.File == nil && t.ErrTransfer != nil && !t.Connection.User.HasBufferedSFTP(t.GetVirtualPath()) {
return false
}
sizeDiff := fileSize - t.InitialSize

View file

@ -348,6 +348,15 @@ func (u *User) GetPermissionsForPath(p string) []string {
return permissions
}
// HasBufferedSFTP returns true if the user has a SFTP filesystem with buffering enabled
func (u *User) HasBufferedSFTP(name string) bool {
fs := u.GetFsConfigForPath(name)
if fs.Provider == sdk.SFTPFilesystemProvider {
return fs.SFTPConfig.BufferSize > 0
}
return false
}
func (u *User) getForbiddenSFTPSelfUsers(username string) ([]string, error) {
sftpUser, err := UserExists(username)
if err == nil {

View file

@ -10,11 +10,14 @@
</head>
<body>
<textarea id="textarea_test" name="textarea_test" rows="6" cols="80">The text here will be sent to SFTPGo as blob</textarea>
<textarea id="textarea_test" name="textarea_test" rows="10" cols="80">The text here will be sent to SFTPGo as blob</textarea>
<br>
<button onclick="saveBlob(false);">Save</button>
<br>
<button onclick="saveBlob(true);">Save binary file</button>
<br><br>
<b>Logs</b>
<pre id="log"></pre>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script type="text/javascript">
@ -24,7 +27,7 @@
// in real world usage set the origin when you call postMessage, we use `*` for testing purpose here
$(document).ready(function () {
if (window.opener == null || window.opener.closed) {
console.log("window opener gone!");
printLog("window opener gone!");
return;
}
// notify SFTPGo that the page is ready to receive the file
@ -33,30 +36,34 @@
window.addEventListener('message', (event) => {
if (window.opener == null || window.opener.closed) {
console.log("window opener gone!");
printLog("window opener gone!");
return;
}
// you should check the origin before continuing
console.log("new message: "+JSON.stringify(event.data));
printLog("new message: "+JSON.stringify(event.data));
switch (event.data.type){
case 'readyResponse':
// after sending the ready request SFTPGo will reply with this response
// now you know the file name and the SFTPGo user
fileName = event.data.file_name;
sftpgoUser = event.data.user;
console.log("ready response received, file name: " + fileName+" SFTPGo user: "+sftpgoUser);
printLog("ready response received, file name: " + fileName+" SFTPGo user: "+sftpgoUser);
// you can initialize your viewer/editor based on the file extension and request the blob
window.opener.postMessage({type: 'sendBlob'}, "*");
break;
case 'blobDownloadStart':
// SFTPGo may take a while to read the file, just before it starts reading it will send this message.
// You can initialize a spinner if required for this file or simply ignore this message
console.log("blob download start received, file name: " + fileName+" SFTPGo user: "+sftpgoUser);
printLog("blob download start received, file name: " + fileName+" SFTPGo user: "+sftpgoUser);
break;
case 'blobDownloadError':
// SFTPGo was unable to download the file and return it as blob
printLog("blob download failed, file name: " + fileName+" SFTPGo user: "+sftpgoUser+" error message: "+event.data.message);
break;
case 'blob':
// we received the file as blob
var extension = fileName.slice((fileName.lastIndexOf(".") - 1 >>> 0) + 2).toLowerCase();
console.log("blob received, file name: " + fileName+" extension: "+extension+" SFTPGo user: "+sftpgoUser);
printLog("blob received, file name: " + fileName+" extension: "+extension+" SFTPGo user: "+sftpgoUser);
if (extension == "txt"){
event.data.file.text().then(function(text){
$("#textarea_test").val(text);
@ -65,25 +72,25 @@
break;
case 'blobSaveResult':
// event.data.status is OK or KO, if KO message is not empty
console.log("blob save status: "+event.data.status+", message: "+event.data.message);
printLog("blob save status: "+event.data.status+", message: "+event.data.message);
if (event.data.status == "OK"){
console.log("blob saved, I'm useless now, close me");
printLog("blob saved, I'm useless now, close me");
}
break;
default:
console.log("Unsupported message: " + JSON.stringify(event.data));
printLog("Unsupported message: " + JSON.stringify(event.data));
}
});
function saveBlob(binary){
if (window.opener == null || window.opener.closed) {
console.log("window opener gone!");
printLog("window opener gone!");
return;
}
// if we have modified the file we can send it back to SFTPGo as a blob for saving
console.log("save blob, binary? "+binary);
printLog("save blob, binary? "+binary);
if (binary){
// we download and save the SFTPGo logo
// we download and save the SFTPGo logo. In a real application check errors
fetch('https://raw.githubusercontent.com/drakkan/sftpgo/main/docs/howto/img/logo.png')
.then(response => response.blob())
.then(function(responseBlob){
@ -101,5 +108,11 @@
},"*");
}
}
function printLog(message){
console.log(message);
var logger = document.getElementById('log');
logger.innerHTML += message + '<br>';
}
</script>
</body>

10
go.mod
View file

@ -7,7 +7,7 @@ require (
github.com/Azure/azure-storage-blob-go v0.14.0
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
github.com/aws/aws-sdk-go v1.42.19
github.com/aws/aws-sdk-go v1.42.20
github.com/cockroachdb/cockroach-go/v2 v2.2.4
github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
github.com/fclairamb/ftpserverlib v0.16.0
@ -25,7 +25,7 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.0
github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
github.com/klauspost/compress v1.13.6
github.com/lestrrat-go/jwx v1.2.12
github.com/lestrrat-go/jwx v1.2.13
github.com/lib/pq v1.10.4
github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mattn/go-sqlite3 v1.14.9
@ -53,7 +53,7 @@ require (
gocloud.dev v0.24.0
golang.org/x/crypto v0.0.0-20211202192323-5770296d904e
golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c
golang.org/x/sys v0.0.0-20211204120058-94396e421777
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
google.golang.org/api v0.61.0
google.golang.org/grpc v1.42.0
@ -62,7 +62,7 @@ require (
)
require (
cloud.google.com/go v0.98.0 // indirect
cloud.google.com/go v0.99.0 // indirect
github.com/Azure/azure-pipeline-go v0.2.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.0.1 // indirect
@ -129,7 +129,7 @@ require (
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9 // indirect
google.golang.org/genproto v0.0.0-20211207154714-918901c715cf // indirect
gopkg.in/ini.v1 v1.66.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect

23
go.sum
View file

@ -33,8 +33,8 @@ cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+Y
cloud.google.com/go v0.94.0/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
cloud.google.com/go v0.98.0 h1:w6LozQJyDDEyhf64Uusu1LCcnLt0I1VMLiJC2kV+eXk=
cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM=
cloud.google.com/go v0.99.0 h1:y/cM2iqGgGi5D5DQZl6D9STN/3dR/Vx5Mp8s752oJTY=
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
@ -136,8 +136,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go v1.42.19 h1:L/aM1QwsqVia9qIqexTHwYN+lgLYuOtf11VDgz0YIyw=
github.com/aws/aws-sdk-go v1.42.19/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go v1.42.20 h1:nQkkmTWK5N2Ao1iVzoOx1HTIxwbSWErxyZ1eiwLJWc4=
github.com/aws/aws-sdk-go v1.42.20/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
@ -301,7 +301,6 @@ github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/goccy/go-json v0.7.6/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.7.10/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.8.1 h1:4/Wjm0JIJaTDm8K1KcGrLHJoa8EsJ13YWeX+6Kfq6uI=
github.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@ -566,8 +565,8 @@ github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++
github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A=
github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
github.com/lestrrat-go/jwx v1.2.6/go.mod h1:tJuGuAI3LC71IicTx82Mz1n3w9woAs2bYJZpkjJQ5aU=
github.com/lestrrat-go/jwx v1.2.12 h1:XG2R0T2w5YFBy7Clfionp6cDdDPuo3yU5xFJg5/r91o=
github.com/lestrrat-go/jwx v1.2.12/go.mod h1:25DcLbNWArPA/Ew5CcBmewl32cJKxOk5cbepBsIJFzw=
github.com/lestrrat-go/jwx v1.2.13 h1:GxuOPfAz4+nzL98WaKxBxEEZ9b7qmyDetMGfBm9yVvE=
github.com/lestrrat-go/jwx v1.2.13/go.mod h1:3Q3Re8TaOcVTdpx4Tvz++OWmryDklihTDqrrwQiyS2A=
github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@ -991,8 +990,8 @@ golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211204120058-94396e421777 h1:QAkhGVjOxMa+n4mlsAWeAU+BMZmimQAaNiMu+iUi94E=
golang.org/x/sys v0.0.0-20211204120058-94396e421777/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -1197,9 +1196,9 @@ google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEc
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9 h1:fU3FNfL/oBU2D5DvGqiuyVqqn40DdxvaTFHq7aivA3k=
google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211207154714-918901c715cf h1:PSEM+IQFb9xdsj2CGhfqUTfsZvF8DScCVP1QZb2IiTQ=
google.golang.org/genproto v0.0.0-20211207154714-918901c715cf/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=

View file

@ -9,6 +9,7 @@ import (
"net/http"
"os"
"path"
"strconv"
"time"
"github.com/go-chi/render"
@ -169,6 +170,85 @@ func getUserFile(w http.ResponseWriter, r *http.Request) {
}
}
func setFileDirMetadata(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
metadata := make(map[string]int64)
err := render.DecodeJSON(r.Body, &metadata)
if err != nil {
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
return
}
mTime, ok := metadata["modification_time"]
if !ok || !r.URL.Query().Has("path") {
sendAPIResponse(w, r, errors.New("please set a modification_time and a path"), "", http.StatusBadRequest)
return
}
connection, err := getUserConnection(w, r)
if err != nil {
return
}
common.Connections.Add(connection)
defer common.Connections.Remove(connection.GetID())
name := util.CleanPath(r.URL.Query().Get("path"))
attrs := common.StatAttributes{
Flags: common.StatAttrTimes,
Atime: util.GetTimeFromMsecSinceEpoch(mTime),
Mtime: util.GetTimeFromMsecSinceEpoch(mTime),
}
err = connection.SetStat(name, &attrs)
if err != nil {
sendAPIResponse(w, r, err, fmt.Sprintf("Unable to set metadata for path %#v", name), getMappedStatusCode(err))
return
}
sendAPIResponse(w, r, nil, "OK", http.StatusOK)
}
func uploadUserFile(w http.ResponseWriter, r *http.Request) {
if maxUploadFileSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadFileSize)
}
if !r.URL.Query().Has("path") {
sendAPIResponse(w, r, errors.New("please set a file path"), "", http.StatusBadRequest)
return
}
connection, err := getUserConnection(w, r)
if err != nil {
return
}
common.Connections.Add(connection)
defer common.Connections.Remove(connection.GetID())
filePath := util.CleanPath(r.URL.Query().Get("path"))
doUploadFile(w, r, connection, filePath) //nolint:errcheck
}
func doUploadFile(w http.ResponseWriter, r *http.Request, connection *Connection, filePath string) error {
writer, err := connection.getFileWriter(filePath)
if err != nil {
sendAPIResponse(w, r, err, fmt.Sprintf("Unable to write file %#v", filePath), getMappedStatusCode(err))
return err
}
_, err = io.Copy(writer, r.Body)
if err != nil {
writer.Close() //nolint:errcheck
sendAPIResponse(w, r, err, fmt.Sprintf("Error saving file %#v", filePath), getMappedStatusCode(err))
return err
}
err = writer.Close()
if err != nil {
sendAPIResponse(w, r, err, fmt.Sprintf("Error closing file %#v", filePath), getMappedStatusCode(err))
return err
}
setModificationTimeFromHeader(r, connection, filePath)
sendAPIResponse(w, r, nil, "Upload completed", http.StatusCreated)
return nil
}
func uploadUserFiles(w http.ResponseWriter, r *http.Request) {
if maxUploadFileSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadFileSize)
@ -468,3 +548,23 @@ func doChangeUserPassword(r *http.Request, currentPassword, newPassword, confirm
return dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, util.GetIPFromRemoteAddress(r.RemoteAddr))
}
func setModificationTimeFromHeader(r *http.Request, c *Connection, filePath string) {
mTimeString := r.Header.Get(mTimeHeader)
if mTimeString != "" {
// we don't return an error here if we fail to set the modification time
mTime, err := strconv.ParseInt(mTimeString, 10, 64)
if err == nil {
attrs := common.StatAttributes{
Flags: common.StatAttrTimes,
Atime: util.GetTimeFromMsecSinceEpoch(mTime),
Mtime: util.GetTimeFromMsecSinceEpoch(mTime),
}
err = c.SetStat(filePath, &attrs)
c.Log(logger.LevelDebug, "requested modification time %v for file %#v, error: %v",
attrs.Mtime, filePath, err)
} else {
c.Log(logger.LevelInfo, "invalid modification time header was ignored: %v", mTimeString)
}
}
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"os"
"path"
"github.com/go-chi/render"
"github.com/rs/xid"
@ -176,7 +177,30 @@ func downloadFromShare(w http.ResponseWriter, r *http.Request) {
}
}
func uploadToShare(w http.ResponseWriter, r *http.Request) {
func uploadFileToShare(w http.ResponseWriter, r *http.Request) {
if maxUploadFileSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadFileSize)
}
name := getURLParam(r, "name")
share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeWrite)
if err != nil {
return
}
filePath := path.Join(share.Paths[0], name)
if path.Dir(filePath) != share.Paths[0] {
sendAPIResponse(w, r, err, "Uploading outside the share is not allowed", http.StatusForbidden)
return
}
dataprovider.UpdateShareLastUse(&share, 1) //nolint:errcheck
common.Connections.Add(connection)
defer common.Connections.Remove(connection.GetID())
if err := doUploadFile(w, r, connection, filePath); err != nil {
dataprovider.UpdateShareLastUse(&share, -1) //nolint:errcheck
}
}
func uploadFilesToShare(w http.ResponseWriter, r *http.Request) {
if maxUploadFileSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadFileSize)
}

View file

@ -62,6 +62,8 @@ const (
userFilePath = "/api/v2/user/file"
userFilesPath = "/api/v2/user/files"
userStreamZipPath = "/api/v2/user/streamzip"
userUploadFilePath = "/api/v2/user/files/upload"
userFilesDirsMetadataPath = "/api/v2/user/files/metadata"
apiKeysPath = "/api/v2/apikeys"
adminTOTPConfigsPath = "/api/v2/admin/totp/configs"
adminTOTPGeneratePath = "/api/v2/admin/totp/generate"
@ -120,6 +122,7 @@ const (
webClientTwoFactorPathDefault = "/web/client/twofactor"
webClientTwoFactorRecoveryPathDefault = "/web/client/twofactor-recovery"
webClientFilesPathDefault = "/web/client/files"
webClientFilePathDefault = "/web/client/file"
webClientSharesPathDefault = "/web/client/shares"
webClientSharePathDefault = "/web/client/share"
webClientEditFilePathDefault = "/web/client/editfile"
@ -147,6 +150,7 @@ const (
maxMultipartMem = 10485760 // 10 MB
osWindows = "windows"
otpHeaderCode = "X-SFTPGO-OTP"
mTimeHeader = "X-SFTPGO-MTIME"
)
var (
@ -195,6 +199,7 @@ var (
webClientTwoFactorPath string
webClientTwoFactorRecoveryPath string
webClientFilesPath string
webClientFilePath string
webClientSharesPath string
webClientSharePath string
webClientEditFilePath string
@ -578,6 +583,7 @@ func updateWebClientURLs(baseURL string) {
webClientTwoFactorPath = path.Join(baseURL, webClientTwoFactorPathDefault)
webClientTwoFactorRecoveryPath = path.Join(baseURL, webClientTwoFactorRecoveryPathDefault)
webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault)
webClientFilePath = path.Join(baseURL, webClientFilePathDefault)
webClientSharesPath = path.Join(baseURL, webClientSharesPathDefault)
webClientPubSharesPath = path.Join(baseURL, webClientPubSharesPathDefault)
webClientSharePath = path.Join(baseURL, webClientSharePathDefault)

View file

@ -91,6 +91,8 @@ const (
userDirsPath = "/api/v2/user/dirs"
userFilesPath = "/api/v2/user/files"
userStreamZipPath = "/api/v2/user/streamzip"
userUploadFilePath = "/api/v2/user/files/upload"
userFilesDirsMetadataPath = "/api/v2/user/files/metadata"
apiKeysPath = "/api/v2/apikeys"
adminTOTPConfigsPath = "/api/v2/admin/totp/configs"
adminTOTPGeneratePath = "/api/v2/admin/totp/generate"
@ -8677,6 +8679,14 @@ func TestPreUploadHook(t *testing.T) {
rr := executeRequest(req)
checkResponseCode(t, http.StatusCreated, rr)
req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?path=filepre",
bytes.NewBuffer([]byte("single upload content")))
assert.NoError(t, err)
req.Header.Add("Content-Type", writer.FormDataContentType())
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusCreated, rr)
err = os.WriteFile(preActionPath, getExitCodeScriptContent(1), os.ModePerm)
assert.NoError(t, err)
_, err = reader.Seek(0, io.SeekStart)
@ -8688,6 +8698,14 @@ func TestPreUploadHook(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusForbidden, rr)
req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?path=filepre",
bytes.NewBuffer([]byte("single upload content")))
assert.NoError(t, err)
req.Header.Add("Content-Type", writer.FormDataContentType())
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusForbidden, rr)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
@ -8929,6 +8947,94 @@ func TestShareUsage(t *testing.T) {
executeRequest(req)
}
func TestShareUploadSingle(t *testing.T) {
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err)
token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
share := dataprovider.Share{
Name: "test share",
Scope: dataprovider.ShareScopeWrite,
Paths: []string{"/"},
Password: defaultPassword,
MaxTokens: 0,
}
asJSON, err := json.Marshal(share)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, token)
rr := executeRequest(req)
checkResponseCode(t, http.StatusCreated, rr)
objectID := rr.Header().Get("X-Object-ID")
assert.NotEmpty(t, objectID)
content := []byte("shared file content")
modTime := time.Now().Add(-12 * time.Hour)
req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID, "file.txt"), bytes.NewBuffer(content))
assert.NoError(t, err)
req.SetBasicAuth(defaultUsername, defaultPassword)
req.Header.Set("X-SFTPGO-MTIME", strconv.FormatInt(util.GetTimeAsMsSinceEpoch(modTime), 10))
rr = executeRequest(req)
checkResponseCode(t, http.StatusCreated, rr)
info, err := os.Stat(filepath.Join(user.GetHomeDir(), "file.txt"))
if assert.NoError(t, err) {
assert.InDelta(t, util.GetTimeAsMsSinceEpoch(modTime), util.GetTimeAsMsSinceEpoch(info.ModTime()), float64(1000))
}
req, err = http.NewRequest(http.MethodPost, path.Join(webClientPubSharesPath, objectID, "file.txt"), bytes.NewBuffer(content))
assert.NoError(t, err)
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusCreated, rr)
info, err = os.Stat(filepath.Join(user.GetHomeDir(), "file.txt"))
if assert.NoError(t, err) {
assert.InDelta(t, util.GetTimeAsMsSinceEpoch(time.Now()), util.GetTimeAsMsSinceEpoch(info.ModTime()), float64(3000))
}
// we don't allow to create the file in subdirectories
req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID, "%2Fdir%2Ffile1.txt"), bytes.NewBuffer(content))
assert.NoError(t, err)
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusForbidden, rr)
req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID, "dir", "file.dat"), bytes.NewBuffer(content))
assert.NoError(t, err)
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID, "%2F"), bytes.NewBuffer(content))
assert.NoError(t, err)
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
err = os.MkdirAll(filepath.Join(user.GetHomeDir(), "dir"), os.ModePerm)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID, "dir"), bytes.NewBuffer(content))
assert.NoError(t, err)
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusInternalServerError, rr)
assert.Contains(t, rr.Body.String(), "operation unsupported")
share, err = dataprovider.ShareExists(objectID, user.Username)
assert.NoError(t, err)
assert.Equal(t, 2, share.UsedTokens)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID, "file1.txt"), bytes.NewBuffer(content))
assert.NoError(t, err)
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
}
func TestShareUncompressed(t *testing.T) {
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err)
@ -9985,6 +10091,119 @@ func TestWebDirsAPI(t *testing.T) {
checkResponseCode(t, http.StatusNotFound, rr)
}
func TestWebUploadSingleFile(t *testing.T) {
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err)
webAPIToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
content := []byte("test content")
req, err := http.NewRequest(http.MethodPost, userUploadFilePath, bytes.NewBuffer(content))
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
rr := executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "please set a file path")
modTime := time.Now().Add(-24 * time.Hour)
req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?path=file.txt", bytes.NewBuffer(content))
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
req.Header.Set("X-SFTPGO-MTIME", strconv.FormatInt(util.GetTimeAsMsSinceEpoch(modTime), 10))
rr = executeRequest(req)
checkResponseCode(t, http.StatusCreated, rr)
info, err := os.Stat(filepath.Join(user.GetHomeDir(), "file.txt"))
if assert.NoError(t, err) {
assert.InDelta(t, util.GetTimeAsMsSinceEpoch(modTime), util.GetTimeAsMsSinceEpoch(info.ModTime()), float64(1000))
}
// invalid modification time will be ignored
req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?path=file.txt", bytes.NewBuffer(content))
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
req.Header.Set("X-SFTPGO-MTIME", "123abc")
rr = executeRequest(req)
checkResponseCode(t, http.StatusCreated, rr)
info, err = os.Stat(filepath.Join(user.GetHomeDir(), "file.txt"))
if assert.NoError(t, err) {
assert.InDelta(t, util.GetTimeAsMsSinceEpoch(time.Now()), util.GetTimeAsMsSinceEpoch(info.ModTime()), float64(3000))
}
metadataReq := make(map[string]int64)
metadataReq["modification_time"] = util.GetTimeAsMsSinceEpoch(modTime)
asJSON, err := json.Marshal(metadataReq)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPatch, userFilesDirsMetadataPath+"?path=file.txt", bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
info, err = os.Stat(filepath.Join(user.GetHomeDir(), "file.txt"))
if assert.NoError(t, err) {
assert.InDelta(t, util.GetTimeAsMsSinceEpoch(modTime), util.GetTimeAsMsSinceEpoch(info.ModTime()), float64(1000))
}
// missing file
req, err = http.NewRequest(http.MethodPatch, userFilesDirsMetadataPath+"?path=file2.txt", bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
assert.Contains(t, rr.Body.String(), "Unable to set metadata for path")
// invalid JSON
req, err = http.NewRequest(http.MethodPatch, userFilesDirsMetadataPath+"?path=file.txt", bytes.NewBuffer(content))
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
// missing mandatory parameter
req, err = http.NewRequest(http.MethodPatch, userFilesDirsMetadataPath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "please set a modification_time and a path")
metadataReq = make(map[string]int64)
asJSON, err = json.Marshal(metadataReq)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPatch, userFilesDirsMetadataPath+"?path=file.txt", bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "please set a modification_time and a path")
req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?path=%2Fdir%2Ffile.txt", bytes.NewBuffer(content))
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
assert.Contains(t, rr.Body.String(), "Unable to write file")
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?path=file.txt", bytes.NewBuffer(content))
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
metadataReq["modification_time"] = util.GetTimeAsMsSinceEpoch(modTime)
asJSON, err = json.Marshal(metadataReq)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPatch, userFilesDirsMetadataPath+"?path=file.txt", bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
assert.Contains(t, rr.Body.String(), "Unable to retrieve your user")
}
func TestWebFilesAPI(t *testing.T) {
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err)
@ -10281,7 +10500,7 @@ func TestWebUploadErrors(t *testing.T) {
checkResponseCode(t, http.StatusNotFound, rr)
if runtime.GOOS != osWindows {
req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=file.zip", reader)
req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=file.zip", nil)
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
@ -10301,6 +10520,13 @@ func TestWebUploadErrors(t *testing.T) {
checkResponseCode(t, http.StatusForbidden, rr)
assert.Contains(t, rr.Body.String(), "Error closing file")
req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?path=file.zip", bytes.NewBuffer(nil))
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusForbidden, rr)
assert.Contains(t, rr.Body.String(), "Error closing file")
err = os.Chmod(user.GetHomeDir(), os.ModePerm)
assert.NoError(t, err)
}
@ -10577,6 +10803,30 @@ func TestWebUploadSFTP(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusRequestEntityTooLarge, rr)
assert.Contains(t, rr.Body.String(), "denying write due to space limit")
assert.Contains(t, rr.Body.String(), "Unable to write file")
// delete the file
req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=file.txt", nil)
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
user, _, err = httpdtest.GetUserByUsername(sftpUser.Username, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, 0, user.UsedQuotaFiles)
assert.Equal(t, int64(0), user.UsedQuotaSize)
req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?path=file.txt",
bytes.NewBuffer([]byte("test upload single file content")))
assert.NoError(t, err)
req.Header.Add("Content-Type", writer.FormDataContentType())
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusRequestEntityTooLarge, rr)
assert.Contains(t, rr.Body.String(), "denying write due to space limit")
assert.Contains(t, rr.Body.String(), "Error saving file")
// delete the file
req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=file.txt", nil)
assert.NoError(t, err)
@ -10593,6 +10843,19 @@ func TestWebUploadSFTP(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusRequestEntityTooLarge, rr)
assert.Contains(t, rr.Body.String(), "denying write due to space limit")
assert.Contains(t, rr.Body.String(), "Error saving file")
// delete the file
req, err = http.NewRequest(http.MethodDelete, userFilesPath+"?path=file.txt", nil)
assert.NoError(t, err)
setBearerForReq(req, webAPIToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
user, _, err = httpdtest.GetUserByUsername(sftpUser.Username, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, 0, user.UsedQuotaFiles)
assert.Equal(t, int64(0), user.UsedQuotaSize)
_, err = httpdtest.RemoveUser(sftpUser, http.StatusOK)
assert.NoError(t, err)

View file

@ -81,6 +81,7 @@ func (s *httpdServer) listenAndServe() error {
config := &tls.Config{
GetCertificate: certMgr.GetCertificateFunc(),
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1", "h2"},
CipherSuites: util.GetTLSCiphersFromNames(s.binding.TLSCipherSuites),
PreferServerCipherSuites: true,
}
@ -1004,7 +1005,8 @@ func (s *httpdServer) initializeRouter() {
// share API exposed to external users
s.router.Get(sharesPath+"/{id}", downloadFromShare)
s.router.Post(sharesPath+"/{id}", uploadToShare)
s.router.Post(sharesPath+"/{id}", uploadFilesToShare)
s.router.Post(sharesPath+"/{id}/{name}", uploadFileToShare)
s.router.Get(tokenPath, s.getToken)
s.router.Post(adminPath+"/{username}/forgot-password", forgotAdminPassword)
@ -1155,6 +1157,8 @@ func (s *httpdServer) initializeRouter() {
router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Get(userSharesPath+"/{id}", getShareByID)
router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Put(userSharesPath+"/{id}", updateShare)
router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Delete(userSharesPath+"/{id}", deleteShare)
router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Post(userUploadFilePath, uploadUserFile)
router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Patch(userFilesDirsMetadataPath, setFileDirMetadata)
})
if s.renderOpenAPI {
@ -1215,7 +1219,8 @@ func (s *httpdServer) initializeRouter() {
Post(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecoveryPost)
// share API exposed to external users
s.router.Get(webClientPubSharesPath+"/{id}", downloadFromShare)
s.router.Post(webClientPubSharesPath+"/{id}", uploadToShare)
s.router.Post(webClientPubSharesPath+"/{id}", uploadFilesToShare)
s.router.Post(webClientPubSharesPath+"/{id}/{name}", uploadFileToShare)
s.router.Group(func(router chi.Router) {
router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie))
@ -1224,8 +1229,9 @@ func (s *httpdServer) initializeRouter() {
router.Get(webClientLogoutPath, handleWebClientLogout)
router.With(s.refreshCookie).Get(webClientFilesPath, s.handleClientGetFiles)
router.With(s.refreshCookie).Get(webClientViewPDFPath, handleClientViewPDF)
router.With(s.refreshCookie, verifyCSRFHeader).Get(webClientFilePath, getUserFile)
router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
Post(webClientFilesPath, uploadUserFiles)
Post(webClientFilePath, uploadUserFile)
router.With(s.refreshCookie).Get(webClientEditFilePath, handleClientEditFile)
router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
Patch(webClientFilesPath, renameUserFile)

View file

@ -109,6 +109,7 @@ type viewPDFPage struct {
type editFilePage struct {
baseClientPage
CurrentDir string
FileURL string
Path string
Name string
ReadOnly bool
@ -121,6 +122,7 @@ type filesPage struct {
DirsURL string
DownloadURL string
ViewPDFURL string
FileURL string
CanAddFiles bool
CanCreateDirs bool
CanRename bool
@ -412,6 +414,7 @@ func renderEditFilePage(w http.ResponseWriter, r *http.Request, fileName, fileDa
Path: fileName,
Name: path.Base(fileName),
CurrentDir: path.Dir(fileName),
FileURL: webClientFilePath,
ReadOnly: readOnly,
Data: fileData,
}
@ -447,6 +450,7 @@ func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error stri
DownloadURL: webClientDownloadZipPath,
ViewPDFURL: webClientViewPDFPath,
DirsURL: webClientDirsPath,
FileURL: webClientFilePath,
CanAddFiles: user.CanAddFilesFromWeb(dirName),
CanCreateDirs: user.CanAddDirsFromWeb(dirName),
CanRename: user.CanRenameFromWeb(dirName, dirName),
@ -616,6 +620,8 @@ func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.
for idx := range s.binding.WebClientIntegrations {
if util.IsStringInSlice(extension, s.binding.WebClientIntegrations[idx].FileExtensions) {
res["ext_url"] = s.binding.WebClientIntegrations[idx].URL
res["ext_link"] = fmt.Sprintf("%v?path=%v&_=%v", webClientFilePath,
url.QueryEscape(path.Join(name, info.Name())), time.Now().UTC().Unix())
break
}
}

View file

@ -100,7 +100,7 @@ paths:
tags:
- public shares
summary: Upload one or more files to the shared path
description: The share must be defined with the read scope and the associated user must have the upload permission
description: The share must be defined with the write scope and the associated user must have the upload permission
operationId: upload_to_share
requestBody:
content:
@ -137,6 +137,77 @@ paths:
$ref: '#/components/responses/InternalServerError'
default:
$ref: '#/components/responses/DefaultResponse'
/shares/{id}/{fileName}:
parameters:
- name: id
in: path
description: the share id
required: true
schema:
type: string
- name: fileName
in: path
description: the name of the new file. It must be path encoded. Sub directories are not accepted
required: true
schema:
type: string
- name: X-SFTPGO-MTIME
in: header
schema:
type: integer
description: File modification time as unix timestamp in milliseconds
post:
security:
- BasicAuth: []
tags:
- public shares
summary: Upload a single file to the shared path
description: The share must be defined with the write scope and the associated user must have the upload/overwrite permissions
operationId: upload_single_to_share
requestBody:
content:
application/*:
schema:
type: string
format: binary
text/*:
schema:
type: string
format: binary
image/*:
schema:
type: string
format: binary
audio/*:
schema:
type: string
format: binary
video/*:
schema:
type: string
format: binary
required: true
responses:
'201':
description: successful operation
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ApiResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'413':
$ref: '#/components/responses/RequestEntityTooLarge'
'500':
$ref: '#/components/responses/InternalServerError'
default:
$ref: '#/components/responses/DefaultResponse'
/token:
get:
security:
@ -3701,6 +3772,114 @@ paths:
$ref: '#/components/responses/InternalServerError'
default:
$ref: '#/components/responses/DefaultResponse'
/user/files/upload:
post:
tags:
- user APIs
summary: Upload a single file
description: 'Upload a single file for the logged in user to an existing directory. This API does not use multipart/form-data and so no temporary files are created server side but only a single file can be uploaded as POST body'
operationId: create_user_file
parameters:
- in: query
name: path
description: Full file path. It must be path encoded, for example the path "my dir/àdir/file.txt" must be sent as "my%20dir%2F%C3%A0dir%2Ffile.txt". The parent directory must exist. If a file with the same name already exists, it will be overwritten
schema:
type: string
required: true
- in: header
name: X-SFTPGO-MTIME
schema:
type: integer
description: File modification time as unix timestamp in milliseconds
requestBody:
content:
application/*:
schema:
type: string
format: binary
text/*:
schema:
type: string
format: binary
image/*:
schema:
type: string
format: binary
audio/*:
schema:
type: string
format: binary
video/*:
schema:
type: string
format: binary
required: true
responses:
'201':
description: successful operation
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ApiResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'413':
$ref: '#/components/responses/RequestEntityTooLarge'
'500':
$ref: '#/components/responses/InternalServerError'
default:
$ref: '#/components/responses/DefaultResponse'
/user/files/metadata:
patch:
tags:
- user APIs
summary: Set metadata for a file/directory
description: 'Set supported metadata attributes for the specified file or directory'
operationId: setprops_user_file
parameters:
- in: query
name: path
description: Full file/directory path. It must be URL encoded, for example the path "my dir/àdir/file.txt" must be sent as "my%20dir%2F%C3%A0dir%2Ffile.txt"
schema:
type: string
required: true
requestBody:
content:
application/json:
schema:
type: object
properties:
modification_time:
type: integer
description: File modification time as unix timestamp in milliseconds
required: true
responses:
'200':
description: successful operation
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ApiResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'413':
$ref: '#/components/responses/RequestEntityTooLarge'
'500':
$ref: '#/components/responses/InternalServerError'
default:
$ref: '#/components/responses/DefaultResponse'
/user/streamzip:
post:
tags:

View file

@ -105,6 +105,7 @@ func (c Conf) Initialize(configDir string) error {
config := &tls.Config{
GetCertificate: certMgr.GetCertificateFunc(),
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1", "h2"},
CipherSuites: util.GetTLSCiphersFromNames(c.TLSCipherSuites),
PreferServerCipherSuites: true,
}

View file

@ -155,7 +155,7 @@ function deleteAction() {
var selectedRows = table.rows({ selected: true }).count();
if (selectedRows == 1){
var folderName = table.row({ selected: true }).data()[0];
var path = '{{.FolderTemplateURL}}' + "?from=" + fixedEncodeURIComponent(folderName);
var path = '{{.FolderTemplateURL}}' + "?from=" + encodeURIComponent(folderName);
window.location.href = path;
} else {
window.location.href = '{{.FolderTemplateURL}}';

View file

@ -159,7 +159,7 @@
titleAttr: "Clone",
action: function (e, dt, node, config) {
var username = dt.row({ selected: true }).data()[1];
var path = '{{.UserURL}}' + "?clone-from=" + fixedEncodeURIComponent(username);
var path = '{{.UserURL}}' + "?clone-from=" + encodeURIComponent(username);
window.location.href = path;
},
enabled: false
@ -172,7 +172,7 @@
var selectedRows = table.rows({ selected: true }).count();
if (selectedRows == 1){
var username = dt.row({ selected: true }).data()[1];
var path = '{{.UserTemplateURL}}' + "?from=" + fixedEncodeURIComponent(username);
var path = '{{.UserTemplateURL}}' + "?from=" + encodeURIComponent(username);
window.location.href = path;
} else {
window.location.href = '{{.UserTemplateURL}}';

View file

@ -134,43 +134,52 @@
{{if not .ReadOnly}}
function saveFile() {
$('#idSave').addClass("disabled");
cm = document.querySelector('.CodeMirror').CodeMirror;
var path = '{{.FilesURL}}?path={{.CurrentDir}}';
var data = new FormData();
var blob = new Blob([cm.getValue()]);
data.append("filenames", new File([blob], "{{.Name}}"));
$.ajax({
url: path,
type: 'POST',
data: data,
processData: false,
contentType: false,
headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
timeout: 15000,
success: function (result) {
window.location.href = '{{.FilesURL}}?path={{.CurrentDir}}';
async function uploadFile() {
var errorMessage = "Error saving file";
let response;
try {
var uploadPath = '{{.FileURL}}?path='+encodeURIComponent('{{.CurrentDir}}/{{.Name}}');
var cm = document.querySelector('.CodeMirror').CodeMirror;
var blob = new Blob([cm.getValue()]);
response = await fetch(uploadPath, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{.CSRFToken}}'
},
error: function ($xhr, textStatus, errorThrown) {
credentials: 'same-origin',
redirect: 'error',
body: blob
});
if (response.status == 201){
window.location.href = '{{.FilesURL}}?path='+encodeURIComponent('{{.CurrentDir}}');
} else {
let jsonResponse;
try {
jsonResponse = await response.json();
} catch(e){
throw Error(errorMessage);
}
if (jsonResponse.message) {
errorMessage = jsonResponse.message;
}
if (jsonResponse.error) {
errorMessage += ": " + jsonResponse.error;
}
throw Error(errorMessage);
}
} catch (e){
throw Error(errorMessage+": " +e.message);
}
}
uploadFile().catch(function(error){
$('#idSave').removeClass("disabled");
var txt = "Error saving file";
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message) {
txt = json.message;
}
if (json.error) {
txt += ": " + json.error;
}
}
}
$('#errorTxt').text(txt);
$('#errorTxt').text(error.message);
$('#errorMsg').show();
setTimeout(function () {
$('#errorMsg').hide();
}, 5000);
}
});
}
{{end}}

View file

@ -210,6 +210,18 @@
}
}
function notifyBlobDownloadError(message) {
if (childReference == null || childReference.closed) {
console.log("external window null or closed, cannot notify download error");
return;
}
childReference.postMessage({
type: 'blobDownloadError',
message: message
}, childProps.get('url'));
}
function notifySave(status, message) {
if (childReference == null || childReference.closed) {
console.log("external window null or closed, cannot notify save");
@ -243,70 +255,118 @@
}, childProps.get('url'));
break;
case 'sendBlob':
// we have to download the blob, this can require some time so
// we have to download the blob, this could require some time so
// we first send a blobDownloadStart message so the child can
// show a spinner or something similar
var errorMessage = "Error downloading file";
childReference.postMessage({
type: 'blobDownloadStart'
}, childProps.get('url'));
// download the file and send as blob to the child window
fetch(childProps.get('link'))
.then(response => response.blob())
.then(function(responseBlob){
// download the file and send it as blob to the child window
async function downloadFileAsBlob(){
var errorMessage = "Error downloading file";
let response;
try {
response = await fetch(childProps.get('link'),{
headers: {
'X-CSRF-TOKEN': '{{.CSRFToken}}'
},
credentials: 'same-origin',
redirect: 'error'
});
} catch (e){
throw Error(errorMessage+": " +e.message);
}
if (response.status == 200){
let responseBlob;
try {
responseBlob = await response.blob();
} catch (e){
throw Error(errorMessage+" as blob: " +e.message);
}
let fileBlob = new File([responseBlob], childProps.get('file_name'), {type: responseBlob.type, lastModified: ""});
childReference.postMessage({
type: 'blob',
file: fileBlob
}, childProps.get('url'));
} else {
let jsonResponse;
try {
jsonResponse = await response.json();
} catch(e){
throw Error(errorMessage);
}
if (jsonResponse.message) {
errorMessage = jsonResponse.message;
}
if (jsonResponse.error) {
errorMessage += ": " + jsonResponse.error;
}
throw Error(errorMessage);
}
}
downloadFileAsBlob().catch(function(error){
notifyBlobDownloadError(error.message);
$('#errorTxt').text(error.message);
$('#errorMsg').show();
setTimeout(function () {
$('#errorMsg').hide();
}, 5000);
});
break;
case 'saveBlob':
// get the blob from the message and save it
var path = '{{.FilesURL}}?path={{.CurrentDir}}';
spinnerDone = false;
var file = new File([event.data.file], childProps.get('file_name'));
var data = new FormData();
data.append('filenames', file);
$.ajax({
url: path,
type: 'POST',
data: data,
processData: false,
contentType: false,
headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
timeout: 0,
beforeSend: function () {
$('#spinnerModal').modal('show');
async function saveBlob() {
var errorMessage = "Error saving external file";
var uploadPath = '{{.FileURL}}?path={{.CurrentDir}}'+encodeURIComponent("/"+childProps.get('file_name'));
let response;
try {
response = await fetch(uploadPath, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{.CSRFToken}}'
},
success: function (result) {
credentials: 'same-origin',
redirect: 'error',
body: event.data.file
});
} catch (e){
throw Error(errorMessage+": " +e.message);
}
if (response.status == 201){
$('#spinnerModal').modal('hide');
notifySave("OK", "");
setTimeout(function () {
location.reload();
}, 2000);
},
error: function ($xhr, textStatus, errorThrown) {
} else {
let jsonResponse;
try {
jsonResponse = await response.json();
} catch(e){
throw Error(errorMessage);
}
if (jsonResponse.message) {
errorMessage = jsonResponse.message;
}
if (jsonResponse.error) {
errorMessage += ": " + jsonResponse.error;
}
throw Error(errorMessage);
}
}
saveBlob().catch(function(error){
$('#spinnerModal').modal('hide');
var txt = "Error saving external file";
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message) {
txt = json.message;
}
if (json.error) {
txt += ": " + json.error;
}
}
}
notifySave("KO", txt);
$('#errorTxt').text(txt);
notifySave("KO", error.message);
$('#errorTxt').text(error.message);
$('#errorMsg').show();
setTimeout(function () {
$('#errorMsg').hide();
}, 5000);
}
});
break;
@ -465,7 +525,7 @@
} else {
path = '{{.FilesURL}}';
}
path+='?path={{.CurrentDir}}'+fixedEncodeURIComponent("/"+itemName);
path+='?path={{.CurrentDir}}'+encodeURIComponent("/"+itemName);
$.ajax({
url: path,
@ -526,7 +586,7 @@
event.preventDefault();
$('#createDirModal').modal('hide');
var dirName = replaceSlash($("#directory_name").val());
var path = '{{.DirsURL}}?path={{.CurrentDir}}' + fixedEncodeURIComponent("/"+dirName);
var path = '{{.DirsURL}}?path={{.CurrentDir}}' + encodeURIComponent("/"+dirName);
$.ajax({
url: path,
type: 'POST',
@ -562,7 +622,6 @@
event.preventDefault();
keepAlive();
var keepAliveTimer = setInterval(keepAlive, 300000);
var path = '{{.FilesURL}}?path={{.CurrentDir}}';
var files = $("#files_name")[0].files;
var has_errors = false;
@ -584,48 +643,57 @@
}
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) {
async function saveFile() {
//console.log("save file, index: "+index);
var errorMessage = "Error uploading files";
let response;
try {
var f = files[index];
var uploadPath = '{{.FileURL}}?path={{.CurrentDir}}'+encodeURIComponent("/"+f.name);
response = await fetch(uploadPath, {
method: 'POST',
headers: {
'X-SFTPGO-MTIME': f.lastModified,
'X-CSRF-TOKEN': '{{.CSRFToken}}'
},
credentials: 'same-origin',
redirect: 'error',
body: f
});
} catch (e){
throw Error(errorMessage+": " +e.message);
}
if (response.status == 201){
index++;
success++;
uploadFile();
},
error: function ($xhr, textStatus, errorThrown) {
} else {
let jsonResponse;
try {
jsonResponse = await response.json();
} catch(e){
throw Error(errorMessage);
}
if (jsonResponse.message) {
errorMessage = jsonResponse.message;
}
if (jsonResponse.error) {
errorMessage += ": " + jsonResponse.error;
}
throw Error(errorMessage);
}
}
saveFile().catch(function(error){
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);
$('#errorTxt').text(error.message);
$('#errorMsg').show();
setTimeout(function () {
$('#errorMsg').hide();
}, 10000);
uploadFile();
}
});
}
@ -646,7 +714,7 @@
} else {
path = '{{.FilesURL}}';
}
path+='?path={{.CurrentDir}}'+fixedEncodeURIComponent("/"+itemName)+'&target={{.CurrentDir}}'+fixedEncodeURIComponent("/"+targetName);
path+='?path={{.CurrentDir}}'+encodeURIComponent("/"+itemName)+'&target={{.CurrentDir}}'+encodeURIComponent("/"+targetName);
$('renameModal').modal('hide');
$.ajax({
url: path,
@ -698,7 +766,7 @@
for (i = 0; i < selected.length; i++) {
filesArray.push(getNameFromMeta(selected[i]));
}
var files = fixedEncodeURIComponent(JSON.stringify(filesArray));
var files = encodeURIComponent(JSON.stringify(filesArray));
var downloadURL = '{{.DownloadURL}}';
var currentDir = '{{.CurrentDir}}';
var ts = new Date().getTime().toString();
@ -763,7 +831,7 @@
for (i = 0; i < selected.length; i++) {
filesArray.push(getNameFromMeta(selected[i]));
}
var files = fixedEncodeURIComponent(JSON.stringify(filesArray));
var files = encodeURIComponent(JSON.stringify(filesArray));
var shareURL = '{{.ShareURL}}';
var currentDir = '{{.CurrentDir}}';
var ts = new Date().getTime().toString();
@ -808,6 +876,7 @@
if (!data.sftpgo_dir || data.sftpgo_dir != '{{.CurrentDir}}'){
data.start = 0;
data.search.search = "";
data.checkboxes = [];
}
},
"columns": [
@ -874,7 +943,7 @@
{{if .HasIntegrations}}
if (type === 'display') {
if (data){
return `<a href="#" onclick="openExternalURL('${data}', '${row["url"]}', '${row["name"]}');"><i class="fas fa-external-link-alt"></i></a>`;
return `<a href="#" onclick="openExternalURL('${data}', '${row["ext_link"]}', '${row["name"]}');"><i class="fas fa-external-link-alt"></i></a>`;
}
}
{{end}}

View file

@ -99,6 +99,9 @@
<p>You can upload one or more files to the shared directory by sending a multipart/form-data request to this <a id="writeLink" href="#" target="_blank">link</a>. The form field name for the file(s) is <b><code>filenames</code></b>.</p>
<p>For example:</p>
<p><code>curl -F filenames=@file1.txt -F filenames=@file2.txt "share link"</code></p>
<p>You can upload files one by one by adding the path encoded file name to the share <a id="writeLinkSingle" href="#" target="_blank">link</a> and sending the file as POST body. The optional <b><code>X-SFTPGO-MTIME</code></b> header allows to set the file modification time as milliseconds since epoch.</p>
<p>For example:</p>
<p><code>curl --data-binary @file.txt -H "Content-Type: application/octet-stream" -H "X-SFTPGO-MTIME: 1638882991234" "share link/file.txt"</code></p>
</div>
<div id="expiredShare">
This share is no longer accessible because it has expired
@ -222,6 +225,8 @@
$('#readShare').hide();
$('#writeLink').attr("href", shareURL);
$('#writeLink').attr("title", shareURL);
$('#writeLinkSingle').attr("href", shareURL);
$('#writeLinkSingle').attr("title", shareURL);
}
}
$('#linkModal').modal('show');

View file

@ -59,6 +59,7 @@ func (s *webDavServer) listenAndServe(compressor *middleware.Compressor) error {
httpServer.TLSConfig = &tls.Config{
GetCertificate: certMgr.GetCertificateFunc(),
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1", "h2"},
CipherSuites: util.GetTLSCiphersFromNames(s.binding.TLSCipherSuites),
PreferServerCipherSuites: true,
}