From c153330ab80598d3c1022ef3117f935a06a0a3f1 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Wed, 8 Dec 2021 19:25:22 +0100 Subject: [PATCH] web client: use fetch to upload files also add REST API to upload a single file as POST body --- common/transfer.go | 2 +- dataprovider/user.go | 9 + examples/webclient-integrations/test.html | 39 ++-- go.mod | 10 +- go.sum | 23 +- httpd/api_http_user.go | 100 ++++++++ httpd/api_shares.go | 26 ++- httpd/httpd.go | 6 + httpd/httpd_test.go | 265 +++++++++++++++++++++- httpd/server.go | 12 +- httpd/webclient.go | 6 + openapi/openapi.yaml | 181 ++++++++++++++- telemetry/telemetry.go | 1 + templates/webadmin/folders.html | 2 +- templates/webadmin/users.html | 4 +- templates/webclient/editfile.html | 79 ++++--- templates/webclient/files.html | 241 +++++++++++++------- templates/webclient/shares.html | 5 + webdavd/server.go | 1 + 19 files changed, 851 insertions(+), 161 deletions(-) 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 @@ - +

+

+ 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, }