diff --git a/common/transfer.go b/common/transfer.go
index f45d7e47..0700deb3 100644
--- a/common/transfer.go
+++ b/common/transfer.go
@@ -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
diff --git a/dataprovider/user.go b/dataprovider/user.go
index 5b2b1a29..4cdf8112 100644
--- a/dataprovider/user.go
+++ b/dataprovider/user.go
@@ -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 {
diff --git a/examples/webclient-integrations/test.html b/examples/webclient-integrations/test.html
index 30dc14da..15148f67 100644
--- a/examples/webclient-integrations/test.html
+++ b/examples/webclient-integrations/test.html
@@ -10,11 +10,14 @@
-
+
Save
Save binary file
+
+ Logs
+
diff --git a/go.mod b/go.mod
index e0969d65..13335a27 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index fdc130bf..2a2b1de5 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/httpd/api_http_user.go b/httpd/api_http_user.go
index b1e54b3b..73e5ca85 100644
--- a/httpd/api_http_user.go
+++ b/httpd/api_http_user.go
@@ -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)
+ }
+ }
+}
diff --git a/httpd/api_shares.go b/httpd/api_shares.go
index 18f2749e..6f4fbb72 100644
--- a/httpd/api_shares.go
+++ b/httpd/api_shares.go
@@ -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)
}
diff --git a/httpd/httpd.go b/httpd/httpd.go
index 775dc05f..d633dc41 100644
--- a/httpd/httpd.go
+++ b/httpd/httpd.go
@@ -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)
diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go
index 0a1f4e94..a9576e9a 100644
--- a/httpd/httpd_test.go
+++ b/httpd/httpd_test.go
@@ -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)
diff --git a/httpd/server.go b/httpd/server.go
index 852243fe..b9d554a9 100644
--- a/httpd/server.go
+++ b/httpd/server.go
@@ -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)
diff --git a/httpd/webclient.go b/httpd/webclient.go
index e6b2e49f..763fd84f 100644
--- a/httpd/webclient.go
+++ b/httpd/webclient.go
@@ -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
}
}
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 27166bd7..b4d14734 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -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:
diff --git a/telemetry/telemetry.go b/telemetry/telemetry.go
index 57d21b0b..9fc45e7a 100644
--- a/telemetry/telemetry.go
+++ b/telemetry/telemetry.go
@@ -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,
}
diff --git a/templates/webadmin/folders.html b/templates/webadmin/folders.html
index 528d2fc6..b1f94e56 100644
--- a/templates/webadmin/folders.html
+++ b/templates/webadmin/folders.html
@@ -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}}';
diff --git a/templates/webadmin/users.html b/templates/webadmin/users.html
index b9569a68..0190c132 100644
--- a/templates/webadmin/users.html
+++ b/templates/webadmin/users.html
@@ -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}}';
diff --git a/templates/webclient/editfile.html b/templates/webclient/editfile.html
index 215a4e29..81ff4f53 100644
--- a/templates/webclient/editfile.html
+++ b/templates/webclient/editfile.html
@@ -134,44 +134,53 @@
{{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}}';
- },
- error: function ($xhr, textStatus, errorThrown) {
- $('#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;
- }
- }
+ 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}}'
+ },
+ 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);
}
- $('#errorTxt').text(txt);
- $('#errorMsg').show();
- setTimeout(function () {
- $('#errorMsg').hide();
- }, 5000);
+ 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");
+ $('#errorTxt').text(error.message);
+ $('#errorMsg').show();
+ setTimeout(function () {
+ $('#errorMsg').hide();
+ }, 5000);
+ });
}
{{end}}
diff --git a/templates/webclient/files.html b/templates/webclient/files.html
index 08f4db52..47977abf 100644
--- a/templates/webclient/files.html
+++ b/templates/webclient/files.html
@@ -210,7 +210,19 @@
}
}
- function notifySave(status, message){
+ 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");
return;
@@ -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);
+ $('#spinnerModal').modal('show');
- $.ajax({
- url: path,
- type: 'POST',
- data: data,
- processData: false,
- contentType: false,
- headers: { 'X-CSRF-TOKEN': '{{.CSRFToken}}' },
- timeout: 0,
- beforeSend: function () {
- $('#spinnerModal').modal('show');
- },
- success: function (result) {
+ 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}}'
+ },
+ 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) {
- $('#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;
- }
- }
+ } else {
+ let jsonResponse;
+ try {
+ jsonResponse = await response.json();
+ } catch(e){
+ throw Error(errorMessage);
}
- notifySave("KO", txt);
- $('#errorTxt').text(txt);
- $('#errorMsg').show();
- setTimeout(function () {
- $('#errorMsg').hide();
- }, 5000);
+ if (jsonResponse.message) {
+ errorMessage = jsonResponse.message;
+ }
+ if (jsonResponse.error) {
+ errorMessage += ": " + jsonResponse.error;
+ }
+ throw Error(errorMessage);
}
+ }
+
+ saveBlob().catch(function(error){
+ $('#spinnerModal').modal('hide');
+ 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) {
- index++;
- has_errors = true;
- var txt = "Error uploading files";
- if (success > 0){
- txt = "Not all files have been uploaded, please reload the page";
+ } else {
+ let jsonResponse;
+ try {
+ jsonResponse = await response.json();
+ } catch(e){
+ throw Error(errorMessage);
}
- if ($xhr) {
- var json = $xhr.responseJSON;
- if (json) {
- if (json.message) {
- txt = json.message;
- }
- if (json.error) {
- txt += ": " + json.error;
- }
- }
+ if (jsonResponse.message) {
+ errorMessage = jsonResponse.message;
}
- $('#errorTxt').text(txt);
- $('#errorMsg').show();
- setTimeout(function () {
- $('#errorMsg').hide();
- }, 10000);
- uploadFile();
+ if (jsonResponse.error) {
+ errorMessage += ": " + jsonResponse.error;
+ }
+ throw Error(errorMessage);
}
+ }
+
+ saveFile().catch(function(error){
+ index++;
+ has_errors = true;
+ $('#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 ` `;
+ return ` `;
}
}
{{end}}
diff --git a/templates/webclient/shares.html b/templates/webclient/shares.html
index b53560bd..64020962 100644
--- a/templates/webclient/shares.html
+++ b/templates/webclient/shares.html
@@ -99,6 +99,9 @@
You can upload one or more files to the shared directory by sending a multipart/form-data request to this link . The form field name for the file(s) is filenames
.
For example:
curl -F filenames=@file1.txt -F filenames=@file2.txt "share link"
+ You can upload files one by one by adding the path encoded file name to the share link and sending the file as POST body. The optional X-SFTPGO-MTIME
header allows to set the file modification time as milliseconds since epoch.
+ For example:
+ curl --data-binary @file.txt -H "Content-Type: application/octet-stream" -H "X-SFTPGO-MTIME: 1638882991234" "share link/file.txt"
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');
diff --git a/webdavd/server.go b/webdavd/server.go
index 4a37c4ee..1b333aee 100644
--- a/webdavd/server.go
+++ b/webdavd/server.go
@@ -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,
}