Browse Source

shares: add an upload form for shares with write scope

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 3 năm trước cách đây
mục cha
commit
f1832d4478
8 tập tin đã thay đổi với 253 bổ sung36 xóa
  1. 5 5
      go.mod
  2. 10 6
      go.sum
  3. 25 12
      httpd/api_shares.go
  4. 12 0
      httpd/httpd_test.go
  5. 3 2
      httpd/server.go
  6. 37 4
      httpd/webclient.go
  7. 17 7
      templates/webclient/shares.html
  8. 144 0
      templates/webclient/shareupload.html

+ 5 - 5
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.52
+	github.com/aws/aws-sdk-go v1.42.53
 	github.com/cockroachdb/cockroach-go/v2 v2.2.8
 	github.com/coreos/go-oidc/v3 v3.1.0
 	github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
@@ -53,7 +53,7 @@ require (
 	go.etcd.io/bbolt v1.3.6
 	go.uber.org/automaxprocs v1.4.0
 	gocloud.dev v0.24.0
-	golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed
+	golang.org/x/crypto v0.0.0-20220214200702-86341886e292
 	golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd
 	golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
 	golang.org/x/sys v0.0.0-20220209214540-3681064d5158
@@ -64,8 +64,8 @@ require (
 
 require (
 	cloud.google.com/go v0.100.2 // indirect
-	cloud.google.com/go/compute v1.2.0 // indirect
-	cloud.google.com/go/iam v0.1.1 // indirect
+	cloud.google.com/go/compute v1.3.0 // indirect
+	cloud.google.com/go/iam v0.2.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
@@ -139,6 +139,6 @@ require (
 replace (
 	github.com/eikenb/pipeat => github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639
 	github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
-	golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20220130095207-a206cf284b7c
+	golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20220215181150-74469fa99b22
 	golang.org/x/net => github.com/drakkan/net v0.0.0-20220130095023-bd85f1236c34
 )

+ 10 - 6
go.sum

@@ -46,14 +46,16 @@ cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUM
 cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
 cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
 cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
-cloud.google.com/go/compute v1.2.0 h1:EKki8sSdvDU0OO9mAXGwPXOTOgPz2l08R0/IutDH11I=
 cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw=
+cloud.google.com/go/compute v1.3.0 h1:mPL/MzDDYHsh5tHRS9mhmhWlcgClCrCa6ApQCU6wnHI=
+cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
 cloud.google.com/go/firestore v1.5.0/go.mod h1:c4nNYR1qdq7eaZ+jSc5fonrQN2k3M7sWATcYTiakjEo=
 cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
-cloud.google.com/go/iam v0.1.1 h1:4CapQyNFjiksks1/x7jsvsygFPhihslYk5GptIrlX68=
 cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
+cloud.google.com/go/iam v0.2.0 h1:Ouq6qif4mZdXkb3SiFMpxvu0JQJB1Yid9TsZ23N6hg8=
+cloud.google.com/go/iam v0.2.0/go.mod h1:BCK88+tmjAwnZYfOSizmKCTSFjJHCa18t3DpdGEY13Y=
 cloud.google.com/go/kms v0.1.0 h1:VXAb5OzejDcyhFzIDeZ5n5AUdlsFnCyexuascIwWMj0=
 cloud.google.com/go/kms v0.1.0/go.mod h1:8Qp8PCAypHg4FdmlyW1QRAv09BGQ9Uzh7JnmIZxPk+c=
 cloud.google.com/go/monitoring v0.1.0/go.mod h1:Hpm3XfzJv+UTiXzCG5Ffp0wijzHTC7Cv4eR7o3x/fEE=
@@ -141,8 +143,8 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI
 github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
 github.com/aws/aws-sdk-go v1.37.0/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.52 h1:/+TZ46+0qu9Ph/UwjVrU3SG8OBi87uJLrLiYRNZKbHQ=
-github.com/aws/aws-sdk-go v1.42.52/go.mod h1:OGr6lGMAKGlG9CVrYnWYDKIyb829c6EVBRjxqjmPepc=
+github.com/aws/aws-sdk-go v1.42.53 h1:56T04NWcmc0ZVYFbUc6HdewDQ9iHQFlmS6hj96dRjJs=
+github.com/aws/aws-sdk-go v1.42.53/go.mod h1:OGr6lGMAKGlG9CVrYnWYDKIyb829c6EVBRjxqjmPepc=
 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=
 github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY=
@@ -216,8 +218,8 @@ github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mz
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
 github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
-github.com/drakkan/crypto v0.0.0-20220130095207-a206cf284b7c h1:IqTZK/MGRdMPRyyJQSxDtrEokSJDJl+nreM2/CFYTsg=
-github.com/drakkan/crypto v0.0.0-20220130095207-a206cf284b7c/go.mod h1:SiM6ypd8Xu1xldObYtbDztuUU7xUzMnUULfphXFZmro=
+github.com/drakkan/crypto v0.0.0-20220215181150-74469fa99b22 h1:yHFyJbCfvTY65bTyPOMRGqplA5GNfdZhBVOiYGNeCtY=
+github.com/drakkan/crypto v0.0.0-20220215181150-74469fa99b22/go.mod h1:SiM6ypd8Xu1xldObYtbDztuUU7xUzMnUULfphXFZmro=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
 github.com/drakkan/net v0.0.0-20220130095023-bd85f1236c34 h1:DRayAKtBRaVU3jg58b/HCbkRleByBD5q6NkN1wcJ2RU=
@@ -1082,6 +1084,7 @@ google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFd
 google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
 google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM=
 google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M=
+google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
 google.golang.org/api v0.68.0 h1:9eJiHhwJKIYX6sX2fUZxQLi7pDRA/MYu8c12q6WbJik=
 google.golang.org/api v0.68.0/go.mod h1:sOM8pTpwgflXRhz+oC8H2Dr+UcbMqkPPWNJo88Q7TH8=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
@@ -1171,6 +1174,7 @@ google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ6
 google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20220204002441-d6cc3cc0770e/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20220211171837-173942840c17 h1:2X+CNIheCutWRyKRte8szGxrE5ggtV4U+NKAbh/oLhg=
 google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=

+ 25 - 12
httpd/api_shares.go

@@ -138,7 +138,7 @@ func deleteShare(w http.ResponseWriter, r *http.Request) {
 
 func readBrowsableShareContents(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead)
+	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead, false)
 	if err != nil {
 		return
 	}
@@ -165,7 +165,7 @@ func readBrowsableShareContents(w http.ResponseWriter, r *http.Request) {
 
 func downloadBrowsableSharedFile(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead)
+	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead, false)
 	if err != nil {
 		return
 	}
@@ -211,7 +211,7 @@ func downloadBrowsableSharedFile(w http.ResponseWriter, r *http.Request) {
 
 func downloadFromShare(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead)
+	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead, false)
 	if err != nil {
 		return
 	}
@@ -263,7 +263,7 @@ func uploadFileToShare(w http.ResponseWriter, r *http.Request) {
 		r.Body = http.MaxBytesReader(w, r.Body, maxUploadFileSize)
 	}
 	name := getURLParam(r, "name")
-	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeWrite)
+	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeWrite, false)
 	if err != nil {
 		return
 	}
@@ -285,7 +285,7 @@ func uploadFilesToShare(w http.ResponseWriter, r *http.Request) {
 	if maxUploadFileSize > 0 {
 		r.Body = http.MaxBytesReader(w, r.Body, maxUploadFileSize)
 	}
-	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeWrite)
+	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeWrite, false)
 	if err != nil {
 		return
 	}
@@ -332,40 +332,53 @@ func uploadFilesToShare(w http.ResponseWriter, r *http.Request) {
 }
 
 func checkPublicShare(w http.ResponseWriter, r *http.Request, shareShope dataprovider.ShareScope,
+	isWebClient bool,
 ) (dataprovider.Share, *Connection, error) {
+	renderError := func(err error, message string, statusCode int) {
+		if isWebClient {
+			renderClientMessagePage(w, r, "Unable to access the share", message, statusCode, err, "")
+		} else {
+			sendAPIResponse(w, r, err, message, statusCode)
+		}
+	}
+
 	shareID := getURLParam(r, "id")
 	share, err := dataprovider.ShareExists(shareID, "")
 	if err != nil {
-		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		statusCode := getRespStatus(err)
+		if statusCode == http.StatusNotFound {
+			err = errors.New("share does not exist")
+		}
+		renderError(err, "", statusCode)
 		return share, nil, err
 	}
 	if share.Scope != shareShope {
-		sendAPIResponse(w, r, nil, "Invalid share scope", http.StatusForbidden)
+		renderError(nil, "Invalid share scope", http.StatusForbidden)
 		return share, nil, errors.New("invalid share scope")
 	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
 	ok, err := share.IsUsable(ipAddr)
 	if !ok || err != nil {
-		sendAPIResponse(w, r, err, "", getRespStatus(err))
-		return share, nil, errors.New("login not allowed")
+		renderError(err, "", getRespStatus(err))
+		return share, nil, err
 	}
 	if share.Password != "" {
 		_, password, ok := r.BasicAuth()
 		if !ok {
 			w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
-			sendAPIResponse(w, r, nil, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+			renderError(dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
 			return share, nil, dataprovider.ErrInvalidCredentials
 		}
 		match, err := share.CheckPassword(password)
 		if !match || err != nil {
 			w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
-			sendAPIResponse(w, r, nil, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+			renderError(dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
 			return share, nil, dataprovider.ErrInvalidCredentials
 		}
 	}
 	user, err := dataprovider.UserExists(share.Username)
 	if err != nil {
-		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		renderError(err, "", getRespStatus(err))
 		return share, nil, err
 	}
 	connID := xid.New().String()

+ 12 - 0
httpd/httpd_test.go

@@ -9412,6 +9412,12 @@ func TestShareUploadSingle(t *testing.T) {
 	if assert.NoError(t, err) {
 		assert.InDelta(t, util.GetTimeAsMsSinceEpoch(modTime), util.GetTimeAsMsSinceEpoch(info.ModTime()), float64(1000))
 	}
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "upload"), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
 	req, err = http.NewRequest(http.MethodPost, path.Join(webClientPubSharesPath, objectID, "file.txt"), bytes.NewBuffer(content))
 	assert.NoError(t, err)
 	req.SetBasicAuth(defaultUsername, defaultPassword)
@@ -9668,6 +9674,12 @@ func TestBrowseShares(t *testing.T) {
 	objectID := rr.Header().Get("X-Object-ID")
 	assert.NotEmpty(t, objectID)
 
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "upload"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "Invalid share scope")
+
 	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil)
 	assert.NoError(t, err)
 	rr = executeRequest(req)

+ 3 - 2
httpd/server.go

@@ -1306,8 +1306,9 @@ func (s *httpdServer) setupWebClientRoutes() {
 			Post(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecoveryPost)
 		// share API exposed to external users
 		s.router.Get(webClientPubSharesPath+"/{id}", downloadFromShare)
-		s.router.Get(webClientPubSharesPath+"/{id}/browse", s.handleShareGetFiles)
-		s.router.With(compressor.Handler).Get(webClientPubSharesPath+"/{id}/dirs", s.handleShareGetDirContents)
+		s.router.Get(webClientPubSharesPath+"/{id}/browse", handleShareGetFiles)
+		s.router.Get(webClientPubSharesPath+"/{id}/upload", handleClientUploadToShare)
+		s.router.With(compressor.Handler).Get(webClientPubSharesPath+"/{id}/dirs", handleShareGetDirContents)
 		s.router.Post(webClientPubSharesPath+"/{id}", uploadFilesToShare)
 		s.router.Post(webClientPubSharesPath+"/{id}/{name}", uploadFileToShare)
 

+ 37 - 4
httpd/webclient.go

@@ -46,6 +46,7 @@ const (
 	templateClientShares            = "shares.html"
 	templateClientViewPDF           = "viewpdf.html"
 	templateShareFiles              = "sharefiles.html"
+	templateUploadToShare           = "shareupload.html"
 	pageClientFilesTitle            = "My Files"
 	pageClientSharesTitle           = "Shares"
 	pageClientProfileTitle          = "My Profile"
@@ -55,6 +56,7 @@ const (
 	pageClientForgotPwdTitle        = "SFTPGo WebClient - Forgot password"
 	pageClientResetPwdTitle         = "SFTPGo WebClient - Reset password"
 	pageExtShareTitle               = "Shared files"
+	pageUploadToShareTitle          = "Upload to share"
 )
 
 // condResult is the result of an HTTP request precondition check.
@@ -147,6 +149,12 @@ type shareFilesPage struct {
 	Paths       []dirMapping
 }
 
+type shareUploadPage struct {
+	baseClientPage
+	Share          *dataprovider.Share
+	UploadBasePath string
+}
+
 type clientMessagePage struct {
 	baseClientPage
 	Error   string
@@ -261,6 +269,10 @@ func loadClientTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateClientDir, templateClientBase),
 		filepath.Join(templatesPath, templateClientDir, templateShareFiles),
 	}
+	shareUploadPath := []string{
+		filepath.Join(templatesPath, templateClientDir, templateClientBase),
+		filepath.Join(templatesPath, templateClientDir, templateUploadToShare),
+	}
 
 	filesTmpl := util.LoadTemplate(nil, filesPaths...)
 	profileTmpl := util.LoadTemplate(nil, profilePaths...)
@@ -277,6 +289,7 @@ func loadClientTemplates(templatesPath string) {
 	resetPwdTmpl := util.LoadTemplate(nil, resetPwdPaths...)
 	viewPDFTmpl := util.LoadTemplate(nil, viewPDFPaths...)
 	shareFilesTmpl := util.LoadTemplate(nil, shareFilesPath...)
+	shareUploadTmpl := util.LoadTemplate(nil, shareUploadPath...)
 
 	clientTemplates[templateClientFiles] = filesTmpl
 	clientTemplates[templateClientProfile] = profileTmpl
@@ -293,6 +306,7 @@ func loadClientTemplates(templatesPath string) {
 	clientTemplates[templateResetPassword] = resetPwdTmpl
 	clientTemplates[templateClientViewPDF] = viewPDFTmpl
 	clientTemplates[templateShareFiles] = shareFilesTmpl
+	clientTemplates[templateUploadToShare] = shareUploadTmpl
 }
 
 func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage {
@@ -495,6 +509,16 @@ func renderSharedFilesPage(w http.ResponseWriter, r *http.Request, dirName, erro
 	renderClientTemplate(w, templateShareFiles, data)
 }
 
+func renderUploadToSharePage(w http.ResponseWriter, r *http.Request, share dataprovider.Share) {
+	currentURL := path.Join(webClientPubSharesPath, share.ShareID, "upload")
+	data := shareUploadPage{
+		baseClientPage: getBaseClientPageData(pageUploadToShareTitle, currentURL, r),
+		Share:          &share,
+		UploadBasePath: path.Join(webClientPubSharesPath, share.ShareID),
+	}
+	renderClientTemplate(w, templateUploadToShare, data)
+}
+
 func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, user dataprovider.User,
 	hasIntegrations bool,
 ) {
@@ -590,9 +614,9 @@ func handleWebClientDownloadZip(w http.ResponseWriter, r *http.Request) {
 	renderCompressedFiles(w, connection, name, filesList, nil)
 }
 
-func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.Request) {
+func handleShareGetDirContents(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead)
+	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead, true)
 	if err != nil {
 		return
 	}
@@ -636,9 +660,18 @@ func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.R
 	render.JSON(w, r, results)
 }
 
-func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request) {
+func handleClientUploadToShare(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	share, _, err := checkPublicShare(w, r, dataprovider.ShareScopeWrite, true)
+	if err != nil {
+		return
+	}
+	renderUploadToSharePage(w, r, share)
+}
+
+func handleShareGetFiles(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead)
+	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead, true)
 	if err != nil {
 		return
 	}

+ 17 - 7
templates/webclient/shares.html

@@ -93,16 +93,24 @@
             <div class="modal-body">
                 <div id="readShare">
                     <p>You can download the shared contents, as single zip file, using this <a id="readLink" href="#" target="_blank">link</a>.</p>
-                    <p>If the share consists of a single folder you can browse and download single files using this <a id="readBrowseLink" href="#" target="_blank">link</a>.</p>
+                    <p>If the share consists of a single directory you can browse and download files using this <a id="readBrowseLink" href="#" target="_blank">page</a>.</p>
                     <p>If the share consists of a single file you can download it uncompressed using this <a id="readUncompressedLink" href="#" target="_blank">link</a>.</p>
                 </div>
                 <div id="writeShare">
-                    <p>You can upload one or more files to the shared directory by sending a multipart/form-data request to this <a id="writeLink" href="#" target="_blank">link</a>. The form field name for the file(s) is <b><code>filenames</code></b>.</p>
-                    <p>For example:</p>
-                    <p><code>curl -F filenames=@file1.txt -F filenames=@file2.txt "share link"</code></p>
-                    <p>You can upload files one by one by adding the path encoded file name to the share <a id="writeLinkSingle" href="#" target="_blank">link</a> and sending the file as POST body. The optional <b><code>X-SFTPGO-MTIME</code></b> header allows to set the file modification time as milliseconds since epoch.</p>
-                    <p>For example:</p>
-                    <p><code>curl --data-binary @file.txt -H "Content-Type: application/octet-stream" -H "X-SFTPGO-MTIME: 1638882991234" "share link/file.txt"</code></p>
+                    <p>You can upload one or more files to the shared directory using this <a id="writePageLink" href="#" target="_blank">page</a></p>
+                    <p>
+                        <a data-toggle="collapse" href="#collapseWriteShareAdvanced" aria-expanded="false" aria-controls="collapseWriteShareAdvanced">
+                            Advanced options
+                        </a>
+                    </p>
+                    <div class="collapse" id="collapseWriteShareAdvanced">
+                        <div class="card card-body">
+                            <p>You can upload one or more files to the shared directory by sending a multipart/form-data request to this <a id="writeLink" href="#" target="_blank">link</a>. The form field name for the file(s) is <b><code>filenames</code></b>.</p>
+                            <p>Example: <code>curl -F filenames=@file1.txt -F filenames=@file2.txt "share link"</code></p>
+                            <p>Or you can upload files one by one by adding the path encoded file name to the share <a id="writeLinkSingle" href="#" target="_blank">link</a> and sending the file as POST body. The optional <b><code>X-SFTPGO-MTIME</code></b> header allows to set the file modification time as milliseconds since epoch.</p>
+                            <p>Example: <code>curl --data-binary @file.txt -H "Content-Type: application/octet-stream" -H "X-SFTPGO-MTIME: 1638882991234" "share link/file.txt"</code></p>
+                        </div>
+                    </div>
                 </div>
                 <div id="expiredShare">
                     This share is no longer accessible because it has expired
@@ -226,6 +234,8 @@
                         $('#expiredShare').hide();
                         $('#writeShare').show();
                         $('#readShare').hide();
+                        $('#writePageLink').attr("href", shareURL+"/upload");
+                        $('#writePageLink').attr("title", shareURL+"/upload");
                         $('#writeLink').attr("href", shareURL);
                         $('#writeLink').attr("title", shareURL);
                         $('#writeLinkSingle').attr("href", shareURL);

+ 144 - 0
templates/webclient/shareupload.html

@@ -0,0 +1,144 @@
+{{template "base" .}}
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "page_body"}}
+<div class="row justify-content-center">
+    <div class="col-xl-5 col-lg-6 col-md-8">
+        <div class="card shadow-lg my-5">
+            <div class="card-header py-3">
+                <h6 id="default_title" class="m-0 font-weight-bold text-primary">Upload one or more files to share "{{.Share.Name}}", user "{{.Share.Username}}"</h6>
+                <h6 id="success_title" class="m-0 font-weight-bold text-primary" style="display: none;">Upload completed to share "{{.Share.Name}}", user "{{.Share.Username}}"</h6>
+            </div>
+            <div class="card-body">
+                <div id="errorMsg" class="card mb-4 border-left-warning" style="display: none;">
+                    <div id="errorTxt" class="card-body text-form-error"></div>
+                </div>
+                <div id="successMsg" class="card mb-4 border-left-success" style="display: none;">
+                    <div id="successTxt" class="card-body">
+                        <p>File/s uploaded successfully</p>
+                        <p>If you want to upload other files click <a href="javascript:refreshPage();">here</a></p>
+                    </div>
+                </div>
+                <form id="upload_files_form" action="#" method="POST" enctype="multipart/form-data">
+                    <div class="modal-body">
+                        <input type="file" class="form-control-file" id="files_name" name="filenames" required multiple>
+                    </div>
+                    <button type="submit" class="btn btn-primary float-right mt-3 px-5">Submit</button>
+                </form>
+            </div>
+        </div>
+    </div>
+</div>
+{{end}}
+
+{{define "dialog"}}
+<div class="modal fade" id="spinnerModal" tabindex="-1" role="dialog" data-keyboard="false" data-backdrop="static">
+    <div class="modal-dialog modal-dialog-centered justify-content-center" role="document">
+        <span style="color: #333333;" class="fa fa-spinner fa-spin fa-3x"></span>
+    </div>
+</div>
+{{end}}
+
+{{define "extra_js"}}
+<script type="text/javascript">
+    var spinnerDone = false;
+
+    function refreshPage() {
+        location.reload();
+    }
+
+    $(document).ready(function () {
+        $('#spinnerModal').on('shown.bs.modal', function () {
+            if (spinnerDone){
+                $('#spinnerModal').modal('hide');
+            }
+        });
+
+        $("#upload_files_form").submit(function (event){
+            event.preventDefault();
+            var files = $("#files_name")[0].files;
+            var has_errors = false;
+            var index = 0;
+            var success = 0;
+            spinnerDone = false;
+
+            $('#spinnerModal').modal('show');
+
+            function uploadFile() {
+                if (index >= files.length || has_errors){
+                    $('#spinnerModal').modal('hide');
+                    spinnerDone = true;
+                    if (!has_errors){
+                        $('#errorMsg').hide();
+                        $('#upload_files_form').hide();
+                        $('#default_title').hide();
+                        $('#success_title').show();
+                        $('#successMsg').show();
+                    }
+                    return;
+                }
+
+                async function saveFile() {
+                    var errorMessage = "Error uploading files";
+                    let response;
+                    try {
+                        var f = files[index];
+                        var uploadPath = '{{.UploadBasePath}}/'+fixedEncodeURIComponent(f.name);
+                        var lastModified;
+                        try {
+                            lastModified = f.lastModified;
+                        } catch (e) {
+                            console.log("unable to get last modified time from file: "+e.message);
+                            lastModified = "";
+                        }
+                        response = await fetch(uploadPath, {
+                            method: 'POST',
+                            headers: {
+                                'X-SFTPGO-MTIME': lastModified
+                            },
+                            credentials: 'same-origin',
+                            redirect: 'error',
+                            body: f
+                        });
+                    } catch (e){
+                        throw Error(errorMessage+": " +e.message);
+                    }
+                    if (response.status == 201){
+                        index++;
+                        success++;
+                        uploadFile();
+                    } else {
+                        let jsonResponse;
+                        try {
+                            jsonResponse = await response.json();
+                        } catch(e){
+                            throw Error(errorMessage);
+                        }
+                        if (jsonResponse.message) {
+                            errorMessage = jsonResponse.message;
+                        }
+                        if (jsonResponse.error) {
+                            errorMessage += ": " + jsonResponse.error;
+                        }
+                        throw Error(errorMessage);
+                    }
+                }
+
+                saveFile().catch(function(error){
+                    index++;
+                    has_errors = true;
+                    $('#errorTxt').text(error.message);
+                    $('#errorMsg').show();
+                    setTimeout(function () {
+                        $('#errorMsg').hide();
+                    }, 10000);
+                    uploadFile();
+                });
+            }
+
+            uploadFile();
+        });
+    });
+</script>
+{{end}}