Jelajahi Sumber

users API: add API to create, delete, rename files and directories

Nicola Murino 4 tahun lalu
induk
melakukan
ae8ccadad2
13 mengubah file dengan 1398 tambahan dan 82 penghapusan
  1. 2 0
      config/config.go
  2. 1 0
      docs/full-configuration.md
  3. 4 4
      go.mod
  4. 14 8
      go.sum
  5. 177 30
      httpd/api_http_user.go
  6. 38 1
      httpd/file.go
  7. 101 1
      httpd/handler.go
  8. 9 2
      httpd/httpd.go
  9. 782 31
      httpd/httpd_test.go
  10. 42 1
      httpd/internal_test.go
  11. 218 1
      httpd/schema/openapi.yaml
  12. 8 2
      httpd/server.go
  13. 2 1
      sftpgo.json

+ 2 - 0
config/config.go

@@ -251,6 +251,7 @@ func Init() {
 			CACertificates:     nil,
 			CARevocationLists:  nil,
 			SigningPassphrase:  "",
+			MaxUploadFileSize:  1048576000,
 		},
 		HTTPConfig: httpclient.Config{
 			Timeout:        20,
@@ -1039,6 +1040,7 @@ func setViperDefaults() {
 	viper.SetDefault("httpd.ca_certificates", globalConf.HTTPDConfig.CACertificates)
 	viper.SetDefault("httpd.ca_revocation_lists", globalConf.HTTPDConfig.CARevocationLists)
 	viper.SetDefault("httpd.signing_passphrase", globalConf.HTTPDConfig.SigningPassphrase)
+	viper.SetDefault("httpd.max_upload_file_size", globalConf.HTTPDConfig.MaxUploadFileSize)
 	viper.SetDefault("http.timeout", globalConf.HTTPConfig.Timeout)
 	viper.SetDefault("http.retry_wait_min", globalConf.HTTPConfig.RetryWaitMin)
 	viper.SetDefault("http.retry_wait_max", globalConf.HTTPConfig.RetryWaitMax)

+ 1 - 0
docs/full-configuration.md

@@ -213,6 +213,7 @@ The configuration file contains the following sections:
   - `ca_certificates`, list of strings. Set of root certificate authorities to be used to verify client certificates.
   - `ca_revocation_lists`, list of strings. Set a revocation lists, one for each root CA, to be used to check if a client certificate has been revoked. The revocation lists can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows.
   - `signing_passphrase`, string. Passphrase to use to derive the signing key for JWT and CSRF tokens. If empty a random signing key will be generated each time SFTPGo starts. If you set a signing passphrase you should consider rotating it periodically for added security.
+  - `max_upload_file_size`, integer. Defines the maximum request body size, in bytes, for Web Client/API HTTP upload requests. 0 means no limit. Default: 1048576000.
 - **"telemetry"**, the configuration for the telemetry server, more details [below](#telemetry-server)
   - `bind_port`, integer. The port used for serving HTTP requests. Set to 0 to disable HTTP server. Default: 10000
   - `bind_address`, string. Leave blank to listen on all available network interfaces. On \*NIX you can specify an absolute path to listen on a Unix-domain socket. Default: "127.0.0.1"

+ 4 - 4
go.mod

@@ -8,7 +8,7 @@ require (
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/StackExchange/wmi v1.2.0 // indirect
 	github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8
-	github.com/aws/aws-sdk-go v1.40.3
+	github.com/aws/aws-sdk-go v1.40.6
 	github.com/cockroachdb/cockroach-go/v2 v2.1.1
 	github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
 	github.com/fatih/color v1.12.0 // indirect
@@ -60,9 +60,9 @@ require (
 	golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
 	golang.org/x/net v0.0.0-20210614182718-04defd469f4e
 	golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c
-	golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6
-	google.golang.org/api v0.50.0
-	google.golang.org/genproto v0.0.0-20210719143636-1d5a45f8e492 // indirect
+	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
+	google.golang.org/api v0.51.0
+	google.golang.org/genproto v0.0.0-20210722135532-667f2b7c528f // indirect
 	google.golang.org/grpc v1.39.0
 	google.golang.org/protobuf v1.27.1
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0

+ 14 - 8
go.sum

@@ -22,8 +22,9 @@ cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECH
 cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
 cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
 cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
-cloud.google.com/go v0.84.0 h1:hVhK90DwCdOAYGME/FJd9vNIZye9HBR6Yy3fu4js3N8=
 cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
+cloud.google.com/go v0.87.0 h1:8ZtzmY4a2JIO2sljMbpqkDYxA8aJQveYr3AMa+X40oc=
+cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
 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=
@@ -121,8 +122,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
 github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
-github.com/aws/aws-sdk-go v1.40.3 h1:NzjcLRsb+C9L1dVPajdNbdzkuPBi0pQJWiQW0eYJGo8=
-github.com/aws/aws-sdk-go v1.40.3/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/aws/aws-sdk-go v1.40.6 h1:JCQfi5MD8cW0PCAzr88hj9tj4BdEJkAy8EyAJ6c8I/k=
+github.com/aws/aws-sdk-go v1.40.6/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
@@ -280,6 +281,7 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt
 github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
 github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
+github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -338,6 +340,7 @@ github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
@@ -951,8 +954,8 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6 h1:Vv0JUPWTyeqUq42B2WJ1FeIDjjvGKoA2Ss+Ts0lAVbs=
-golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs=
+golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -1059,8 +1062,9 @@ google.golang.org/api v0.46.0/go.mod h1:ceL4oozhkAiTID8XMmJBsIxID/9wMXJVVFXPg4yl
 google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
 google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
 google.golang.org/api v0.49.0/go.mod h1:BECiH72wsfwUvOVn3+btPD5WHi0LzavZReBndi42L18=
-google.golang.org/api v0.50.0 h1:LX7NFCFYOHzr7WHaYiRUpeipZe9o5L8T+2F4Z798VDw=
 google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
+google.golang.org/api v0.51.0 h1:SQaA2Cx57B+iPw2MBgyjEkoeMkRK2IenSGoia0U3lCk=
+google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -1127,8 +1131,10 @@ google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxH
 google.golang.org/genproto v0.0.0-20210617175327-b9e0b3197ced/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
 google.golang.org/genproto v0.0.0-20210624174822-c5cf32407d0a/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
 google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
-google.golang.org/genproto v0.0.0-20210719143636-1d5a45f8e492 h1:7yQQsvnwjfEahbNNEKcBHv3mR+HnB1ctGY/z1JXzx8M=
-google.golang.org/genproto v0.0.0-20210719143636-1d5a45f8e492/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
+google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
+google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
+google.golang.org/genproto v0.0.0-20210722135532-667f2b7c528f h1:YORWxaStkWBnWgELOHTmDrqNlFXuVGEbhwbB5iK94bQ=
+google.golang.org/genproto v0.0.0-20210722135532-667f2b7c528f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=

+ 177 - 30
httpd/api_http_user.go

@@ -4,7 +4,10 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
+	"os"
+	"path"
 	"time"
 
 	"github.com/go-chi/render"
@@ -12,30 +15,39 @@ import (
 
 	"github.com/drakkan/sftpgo/v2/common"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
+	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/util"
 )
 
-func readUserFolder(w http.ResponseWriter, r *http.Request) {
+func getUserConnection(w http.ResponseWriter, r *http.Request) (*Connection, error) {
 	claims, err := getTokenClaims(r)
 	if err != nil || claims.Username == "" {
 		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
-		return
+		return nil, fmt.Errorf("invalid token claims %w", err)
 	}
 	user, err := dataprovider.UserExists(claims.Username)
 	if err != nil {
 		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
-		return
+		return nil, err
 	}
 	connID := xid.New().String()
 	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID)
 	if err := checkHTTPClientUser(&user, r, connectionID); err != nil {
 		sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-		return
+		return nil, err
 	}
 	connection := &Connection{
 		BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, r.RemoteAddr, user),
 		request:        r,
 	}
+	return connection, nil
+}
+
+func readUserFolder(w http.ResponseWriter, r *http.Request) {
+	connection, err := getUserConnection(w, r)
+	if err != nil {
+		return
+	}
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 
@@ -60,26 +72,66 @@ func readUserFolder(w http.ResponseWriter, r *http.Request) {
 	render.JSON(w, r, results)
 }
 
-func getUserFile(w http.ResponseWriter, r *http.Request) {
-	claims, err := getTokenClaims(r)
-	if err != nil || claims.Username == "" {
-		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+func createUserDir(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	connection, err := getUserConnection(w, r)
+	if err != nil {
 		return
 	}
-	user, err := dataprovider.UserExists(claims.Username)
+	common.Connections.Add(connection)
+	defer common.Connections.Remove(connection.GetID())
+
+	name := util.CleanPath(r.URL.Query().Get("path"))
+	err = connection.CreateDir(name)
 	if err != nil {
-		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
+		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to create directory %#v", name), getMappedStatusCode(err))
 		return
 	}
-	connID := xid.New().String()
-	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID)
-	if err := checkHTTPClientUser(&user, r, connectionID); err != nil {
-		sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+	sendAPIResponse(w, r, nil, fmt.Sprintf("Directory %#v created", name), http.StatusCreated)
+}
+
+func renameUserDir(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	connection, err := getUserConnection(w, r)
+	if err != nil {
 		return
 	}
-	connection := &Connection{
-		BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, r.RemoteAddr, user),
-		request:        r,
+	common.Connections.Add(connection)
+	defer common.Connections.Remove(connection.GetID())
+
+	oldName := util.CleanPath(r.URL.Query().Get("path"))
+	newName := util.CleanPath(r.URL.Query().Get("target"))
+	err = connection.Rename(oldName, newName)
+	if err != nil {
+		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to rename directory %#v to %#v", oldName, newName),
+			getMappedStatusCode(err))
+		return
+	}
+	sendAPIResponse(w, r, nil, fmt.Sprintf("Directory %#v renamed to %#v", oldName, newName), http.StatusOK)
+}
+
+func deleteUserDir(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	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"))
+	err = connection.RemoveDir(name)
+	if err != nil {
+		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to delete directory %#v", name), getMappedStatusCode(err))
+		return
+	}
+	sendAPIResponse(w, r, nil, fmt.Sprintf("Directory %#v deleted", name), http.StatusOK)
+}
+
+func getUserFile(w http.ResponseWriter, r *http.Request) {
+	connection, err := getUserConnection(w, r)
+	if err != nil {
+		return
 	}
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
@@ -112,26 +164,121 @@ func getUserFile(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-func getUserFilesAsZipStream(w http.ResponseWriter, r *http.Request) {
-	claims, err := getTokenClaims(r)
-	if err != nil || claims.Username == "" {
-		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+func uploadUserFiles(w http.ResponseWriter, r *http.Request) {
+	if maxUploadFileSize > 0 {
+		r.Body = http.MaxBytesReader(w, r.Body, maxUploadFileSize)
+	}
+
+	connection, err := getUserConnection(w, r)
+	if err != nil {
 		return
 	}
-	user, err := dataprovider.UserExists(claims.Username)
+	common.Connections.Add(connection)
+	defer common.Connections.Remove(connection.GetID())
+
+	err = r.ParseMultipartForm(maxMultipartMem)
 	if err != nil {
-		sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err))
+		sendAPIResponse(w, r, err, "Unable to parse multipart form", http.StatusBadRequest)
 		return
 	}
-	connID := xid.New().String()
-	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID)
-	if err := checkHTTPClientUser(&user, r, connectionID); err != nil {
-		sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+
+	parentDir := util.CleanPath(r.URL.Query().Get("path"))
+	files := r.MultipartForm.File["filename"]
+	if len(files) == 0 {
+		sendAPIResponse(w, r, err, "No files uploaded!", http.StatusBadRequest)
 		return
 	}
-	connection := &Connection{
-		BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, r.RemoteAddr, user),
-		request:        r,
+
+	for _, f := range files {
+		file, err := f.Open()
+		if err != nil {
+			sendAPIResponse(w, r, err, fmt.Sprintf("Unable to read uploaded file %#v", f.Filename), getMappedStatusCode(err))
+			return
+		}
+		defer file.Close()
+
+		filePath := path.Join(parentDir, f.Filename)
+		writer, err := connection.getFileWriter(filePath)
+		if err != nil {
+			sendAPIResponse(w, r, err, fmt.Sprintf("Unable to write file %#v", f.Filename), getMappedStatusCode(err))
+			return
+		}
+		_, err = io.Copy(writer, file)
+		if err != nil {
+			writer.Close() //nolint:errcheck
+			sendAPIResponse(w, r, err, fmt.Sprintf("Error saving file %#v", f.Filename), getMappedStatusCode(err))
+			return
+		}
+		err = writer.Close()
+		if err != nil {
+			sendAPIResponse(w, r, err, fmt.Sprintf("Error closing file %#v", f.Filename), getMappedStatusCode(err))
+			return
+		}
+	}
+	sendAPIResponse(w, r, nil, "Upload completed", http.StatusCreated)
+}
+
+func renameUserFile(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	connection, err := getUserConnection(w, r)
+	if err != nil {
+		return
+	}
+	common.Connections.Add(connection)
+	defer common.Connections.Remove(connection.GetID())
+
+	oldName := util.CleanPath(r.URL.Query().Get("path"))
+	newName := util.CleanPath(r.URL.Query().Get("target"))
+	err = connection.Rename(oldName, newName)
+	if err != nil {
+		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to rename file %#v to %#v", oldName, newName),
+			getMappedStatusCode(err))
+		return
+	}
+	sendAPIResponse(w, r, nil, fmt.Sprintf("File %#v renamed to %#v", oldName, newName), http.StatusOK)
+}
+
+func deleteUserFile(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	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"))
+	fs, p, err := connection.GetFsAndResolvedPath(name)
+	if err != nil {
+		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to delete file %#v", name), getMappedStatusCode(err))
+		return
+	}
+
+	var fi os.FileInfo
+	if fi, err = fs.Lstat(p); err != nil {
+		connection.Log(logger.LevelWarn, "failed to remove a file %#v: stat error: %+v", p, err)
+		err = connection.GetFsError(fs, err)
+		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to delete file %#v", name), getMappedStatusCode(err))
+		return
+	}
+
+	if fi.IsDir() && fi.Mode()&os.ModeSymlink == 0 {
+		connection.Log(logger.LevelDebug, "cannot remove %#v is not a file/symlink", p)
+		sendAPIResponse(w, r, err, fmt.Sprintf("Unable delete %#v, it is not a file/symlink", name), http.StatusBadRequest)
+		return
+	}
+	err = connection.RemoveFile(fs, p, name, fi)
+	if err != nil {
+		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to delete file %#v", name), getMappedStatusCode(err))
+		return
+	}
+	sendAPIResponse(w, r, nil, fmt.Sprintf("File %#v deleted", name), http.StatusOK)
+}
+
+func getUserFilesAsZipStream(w http.ResponseWriter, r *http.Request) {
+	connection, err := getUserConnection(w, r)
+	if err != nil {
+		return
 	}
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())

+ 38 - 1
httpd/file.go

@@ -8,25 +8,32 @@ import (
 	"github.com/eikenb/pipeat"
 
 	"github.com/drakkan/sftpgo/v2/common"
+	"github.com/drakkan/sftpgo/v2/vfs"
 )
 
 var errTransferAborted = errors.New("transfer aborted")
 
 type httpdFile struct {
 	*common.BaseTransfer
+	writer     io.WriteCloser
 	reader     io.ReadCloser
 	isFinished bool
 }
 
-func newHTTPDFile(baseTransfer *common.BaseTransfer, pipeReader *pipeat.PipeReaderAt) *httpdFile {
+func newHTTPDFile(baseTransfer *common.BaseTransfer, pipeWriter *vfs.PipeWriter, pipeReader *pipeat.PipeReaderAt) *httpdFile {
+	var writer io.WriteCloser
 	var reader io.ReadCloser
 	if baseTransfer.File != nil {
+		writer = baseTransfer.File
 		reader = baseTransfer.File
+	} else if pipeWriter != nil {
+		writer = pipeWriter
 	} else if pipeReader != nil {
 		reader = pipeReader
 	}
 	return &httpdFile{
 		BaseTransfer: baseTransfer,
+		writer:       writer,
 		reader:       reader,
 		isFinished:   false,
 	}
@@ -51,6 +58,28 @@ func (f *httpdFile) Read(p []byte) (n int, err error) {
 	return
 }
 
+// Write writes the contents to upload
+func (f *httpdFile) Write(p []byte) (n int, err error) {
+	if atomic.LoadInt32(&f.AbortTransfer) == 1 {
+		return 0, errTransferAborted
+	}
+
+	f.Connection.UpdateLastActivity()
+
+	n, err = f.writer.Write(p)
+	atomic.AddInt64(&f.BytesReceived, int64(n))
+
+	if f.MaxWriteSize > 0 && err == nil && atomic.LoadInt64(&f.BytesReceived) > f.MaxWriteSize {
+		err = common.ErrQuotaExceeded
+	}
+	if err != nil {
+		f.TransferError(err)
+		return
+	}
+	f.HandleThrottle()
+	return
+}
+
 // Close closes the current transfer
 func (f *httpdFile) Close() error {
 	if err := f.setFinished(); err != nil {
@@ -69,6 +98,14 @@ func (f *httpdFile) closeIO() error {
 	var err error
 	if f.File != nil {
 		err = f.File.Close()
+	} else if f.writer != nil {
+		err = f.writer.Close()
+		f.Lock()
+		// we set ErrTransfer here so quota is not updated, in this case the uploads are atomic
+		if err != nil && f.ErrTransfer == nil {
+			f.ErrTransfer = err
+		}
+		f.Unlock()
 	} else if f.reader != nil {
 		err = f.reader.Close()
 	}

+ 101 - 1
httpd/handler.go

@@ -11,6 +11,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/util"
+	"github.com/drakkan/sftpgo/v2/vfs"
 )
 
 // Connection details for a HTTP connection used to inteact with an SFTPGo filesystem
@@ -107,5 +108,104 @@ func (c *Connection) getFileReader(name string, offset int64, method string) (io
 
 	baseTransfer := common.NewBaseTransfer(file, c.BaseConnection, cancelFn, p, p, name, common.TransferDownload,
 		0, 0, 0, false, fs)
-	return newHTTPDFile(baseTransfer, r), nil
+	return newHTTPDFile(baseTransfer, nil, r), nil
+}
+
+func (c *Connection) getFileWriter(name string) (io.WriteCloser, error) {
+	c.UpdateLastActivity()
+
+	if !c.User.IsFileAllowed(name) {
+		c.Log(logger.LevelWarn, "writing file %#v is not allowed", name)
+		return nil, c.GetPermissionDeniedError()
+	}
+
+	fs, p, err := c.GetFsAndResolvedPath(name)
+	if err != nil {
+		return nil, err
+	}
+	filePath := p
+	if common.Config.IsAtomicUploadEnabled() && fs.IsAtomicUploadSupported() {
+		filePath = fs.GetAtomicUploadPath(p)
+	}
+
+	stat, statErr := fs.Lstat(p)
+	if (statErr == nil && stat.Mode()&os.ModeSymlink != 0) || fs.IsNotExist(statErr) {
+		if !c.User.HasPerm(dataprovider.PermUpload, path.Dir(name)) {
+			return nil, c.GetPermissionDeniedError()
+		}
+		return c.handleUploadFile(fs, p, filePath, name, true, 0)
+	}
+
+	if statErr != nil {
+		c.Log(logger.LevelError, "error performing file stat %#v: %+v", p, statErr)
+		return nil, c.GetFsError(fs, statErr)
+	}
+
+	// This happen if we upload a file that has the same name of an existing directory
+	if stat.IsDir() {
+		c.Log(logger.LevelWarn, "attempted to open a directory for writing to: %#v", p)
+		return nil, c.GetOpUnsupportedError()
+	}
+
+	if !c.User.HasPerm(dataprovider.PermOverwrite, path.Dir(name)) {
+		return nil, c.GetPermissionDeniedError()
+	}
+
+	if common.Config.IsAtomicUploadEnabled() && fs.IsAtomicUploadSupported() {
+		err = fs.Rename(p, filePath)
+		if err != nil {
+			c.Log(logger.LevelWarn, "error renaming existing file for atomic upload, source: %#v, dest: %#v, err: %+v",
+				p, filePath, err)
+			return nil, c.GetFsError(fs, err)
+		}
+	}
+
+	return c.handleUploadFile(fs, p, filePath, name, false, stat.Size())
+}
+
+func (c *Connection) handleUploadFile(fs vfs.Fs, resolvedPath, filePath, requestPath string, isNewFile bool, fileSize int64) (io.WriteCloser, error) {
+	quotaResult := c.HasSpace(isNewFile, false, requestPath)
+	if !quotaResult.HasSpace {
+		c.Log(logger.LevelInfo, "denying file write due to quota limits")
+		return nil, common.ErrQuotaExceeded
+	}
+	err := common.ExecutePreAction(&c.User, common.OperationPreUpload, resolvedPath, requestPath, c.GetProtocol(), fileSize, os.O_TRUNC)
+	if err != nil {
+		c.Log(logger.LevelDebug, "upload for file %#v denied by pre action: %v", requestPath, err)
+		return nil, c.GetPermissionDeniedError()
+	}
+
+	maxWriteSize, _ := c.GetMaxWriteSize(quotaResult, false, fileSize, fs.IsUploadResumeSupported())
+
+	file, w, cancelFn, err := fs.Create(filePath, 0)
+	if err != nil {
+		c.Log(logger.LevelWarn, "error opening existing file, source: %#v, err: %+v", filePath, err)
+		return nil, c.GetFsError(fs, err)
+	}
+
+	initialSize := int64(0)
+	if !isNewFile {
+		if vfs.IsLocalOrSFTPFs(fs) {
+			vfolder, err := c.User.GetVirtualFolderForPath(path.Dir(requestPath))
+			if err == nil {
+				dataprovider.UpdateVirtualFolderQuota(&vfolder.BaseVirtualFolder, 0, -fileSize, false) //nolint:errcheck
+				if vfolder.IsIncludedInUserQuota() {
+					dataprovider.UpdateUserQuota(&c.User, 0, -fileSize, false) //nolint:errcheck
+				}
+			} else {
+				dataprovider.UpdateUserQuota(&c.User, 0, -fileSize, false) //nolint:errcheck
+			}
+		} else {
+			initialSize = fileSize
+		}
+		if maxWriteSize > 0 {
+			maxWriteSize += fileSize
+		}
+	}
+
+	vfs.SetPathPermissions(fs, filePath, c.User.GetUID(), c.User.GetGID())
+
+	baseTransfer := common.NewBaseTransfer(file, c.BaseConnection, cancelFn, resolvedPath, filePath, requestPath,
+		common.TransferUpload, 0, initialSize, maxWriteSize, isNewFile, fs)
+	return newHTTPDFile(baseTransfer, w, nil), nil
 }

+ 9 - 2
httpd/httpd.go

@@ -56,8 +56,8 @@ const (
 	adminPwdCompatPath              = "/api/v2/changepwd/admin"
 	userPwdPath                     = "/api/v2/user/changepwd"
 	userPublicKeysPath              = "/api/v2/user/publickeys"
-	userReadFolderPath              = "/api/v2/user/folder"
-	userGetFilePath                 = "/api/v2/user/file"
+	userFolderPath                  = "/api/v2/user/folder"
+	userFilePath                    = "/api/v2/user/file"
 	userStreamZipPath               = "/api/v2/user/streamzip"
 	healthzPath                     = "/healthz"
 	webRootPathDefault              = "/"
@@ -98,6 +98,7 @@ const (
 	MaxRestoreSize   = 10485760 // 10 MB
 	maxRequestSize   = 1048576  // 1MB
 	maxLoginPostSize = 262144   // 256 KB
+	maxMultipartMem  = 8388608  // 8MB
 	osWindows        = "windows"
 )
 
@@ -142,6 +143,8 @@ var (
 	webChangeClientKeysPath  string
 	webClientLogoutPath      string
 	webStaticFilesPath       string
+	// max upload size for http clients, 1GB by default
+	maxUploadFileSize = int64(1048576000)
 )
 
 func init() {
@@ -250,6 +253,9 @@ type Conf struct {
 	// If empty a random signing key will be generated each time SFTPGo starts. If you set a
 	// signing passphrase you should consider rotating it periodically for added security
 	SigningPassphrase string `json:"signing_passphrase" mapstructure:"signing_passphrase"`
+	// MaxUploadFileSize Defines the maximum request body size, in bytes, for Web Client/API HTTP upload requests.
+	// 0 means no limit
+	MaxUploadFileSize int64 `json:"max_upload_file_size" mapstructure:"max_upload_file_size"`
 }
 
 type apiResponse struct {
@@ -361,6 +367,7 @@ func (c *Conf) Initialize(configDir string) error {
 		}(binding)
 	}
 
+	maxUploadFileSize = c.MaxUploadFileSize
 	startJWTTokensCleanupTicker(tokenDuration)
 	return <-exitChannel
 }

File diff ditekan karena terlalu besar
+ 782 - 31
httpd/httpd_test.go


+ 42 - 1
httpd/internal_test.go

@@ -1456,6 +1456,39 @@ func TestConnection(t *testing.T) {
 	assert.ErrorIs(t, err, os.ErrNotExist)
 }
 
+func TestGetFileWriterErrors(t *testing.T) {
+	user := dataprovider.User{
+		BaseUser: sdk.BaseUser{
+			Username: "test_httpd_user",
+			HomeDir:  "invalid",
+		},
+	}
+	user.Permissions = make(map[string][]string)
+	user.Permissions["/"] = []string{dataprovider.PermAny}
+	connection := &Connection{
+		BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", user),
+		request:        nil,
+	}
+	_, err := connection.getFileWriter("name")
+	assert.Error(t, err)
+
+	user.FsConfig.Provider = sdk.S3FilesystemProvider
+	user.FsConfig.S3Config = vfs.S3FsConfig{
+		S3FsConfig: sdk.S3FsConfig{
+			Bucket:       "b",
+			Region:       "us-west-1",
+			AccessKey:    "key",
+			AccessSecret: kms.NewPlainSecret("secret"),
+		},
+	}
+	connection = &Connection{
+		BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", user),
+		request:        nil,
+	}
+	_, err = connection.getFileWriter("/path")
+	assert.Error(t, err)
+}
+
 func TestHTTPDFile(t *testing.T) {
 	user := dataprovider.User{
 		BaseUser: sdk.BaseUser{
@@ -1484,7 +1517,7 @@ func TestHTTPDFile(t *testing.T) {
 
 	baseTransfer := common.NewBaseTransfer(file, connection.BaseConnection, nil, p, p, name, common.TransferDownload,
 		0, 0, 0, false, fs)
-	httpdFile := newHTTPDFile(baseTransfer, nil)
+	httpdFile := newHTTPDFile(baseTransfer, nil, nil)
 	// the file is closed, read should fail
 	buf := make([]byte, 100)
 	_, err = httpdFile.Read(buf)
@@ -1495,6 +1528,14 @@ func TestHTTPDFile(t *testing.T) {
 	assert.ErrorIs(t, err, common.ErrTransferClosed)
 	err = os.Remove(p)
 	assert.NoError(t, err)
+
+	httpdFile.writer = file
+	httpdFile.File = nil
+	httpdFile.ErrTransfer = nil
+	err = httpdFile.closeIO()
+	assert.Error(t, err)
+	assert.Error(t, httpdFile.ErrTransfer)
+	assert.Equal(t, err, httpdFile.ErrTransfer)
 }
 
 func TestChangeUserPwd(t *testing.T) {

+ 218 - 1
httpd/schema/openapi.yaml

@@ -17,7 +17,7 @@ info:
     Several storage backends are supported and they are configurable per user, so you can serve a local directory for a user and an S3 bucket (or part of it) for another one.
     SFTPGo also supports virtual folders, a virtual folder can use any of the supported storage backends. So you can have, for example, an S3 user that exposes a GCS bucket (or part of it) on a specified path and an encrypted local filesystem on another one.
     Virtual folders can be private or shared among multiple users, for shared virtual folders you can define different quota limits for each user.
-  version: 2.1.0
+  version: 2.1.0-dev
   contact:
     name: API support
     url: 'https://github.com/drakkan/sftpgo'
@@ -1790,6 +1790,108 @@ paths:
           $ref: '#/components/responses/InternalServerError'
         default:
           $ref: '#/components/responses/DefaultResponse'
+    post:
+      tags:
+        - users API
+      summary: Create a directory
+      description: Create a directory for the logged in user
+      operationId: create_user_folder
+      parameters:
+        - in: query
+          name: path
+          description: Path to the folder to create. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir"
+          schema:
+            type: string
+          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'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    patch:
+      tags:
+        - users API
+      summary: Rename a directory
+      description: Rename a directory for the logged in user. The rename is allowed for empty directory or for non empty, local directories, with no virtual folders inside
+      operationId: rename_user_folder
+      parameters:
+        - in: query
+          name: path
+          description: Path to the folder to rename. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir"
+          schema:
+            type: string
+          required: true
+        - in: query
+          name: target
+          description: New name. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir"
+          schema:
+            type: string
+          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'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    delete:
+      tags:
+        - users API
+      summary: Delete a directory
+      description: Delete a directory for the logged in user. Only empty directories can be deleted
+      operationId: delete_user_folder
+      parameters:
+        - in: query
+          name: path
+          description: Path to the folder to delete. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir"
+          schema:
+            type: string
+          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'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /user/file:
     get:
       tags:
@@ -1829,6 +1931,121 @@ paths:
           $ref: '#/components/responses/InternalServerError'
         default:
           $ref: '#/components/responses/DefaultResponse'
+    post:
+      tags:
+        - users API
+      summary: Upload files
+      description: Upload one or more files for the logged in user
+      operationId: create_user_files
+      parameters:
+        - in: query
+          name: path
+          description: Parent directory for the uploaded files. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the root path is assumed. If a file with the same name already exists, it will be overwritten
+          schema:
+            type: string
+      requestBody:
+        content:
+          multipart/form-data:
+            schema:
+              type: object
+              properties:
+                filename:
+                  type: array
+                  items:
+                    type: string
+                    format: binary
+                  minItems: 1
+                  uniqueItems: true
+        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'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    patch:
+      tags:
+        - users API
+      summary: Rename afile
+      description: Rename a file for the logged in user
+      operationId: rename_user_file
+      parameters:
+        - in: query
+          name: path
+          description: Path to the file to rename. It must be URL encoded
+          schema:
+            type: string
+          required: true
+        - in: query
+          name: target
+          description: New name. It must be URL encoded
+          schema:
+            type: string
+          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'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    delete:
+      tags:
+        - users API
+      summary: Delete a file
+      description: Delete a file for the logged in user.
+      operationId: delete_user_file
+      parameters:
+        - in: query
+          name: path
+          description: Path to the file to delete. It must be URL encoded
+          schema:
+            type: string
+          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'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /user/streamzip:
     post:
       tags:

+ 8 - 2
httpd/server.go

@@ -631,8 +631,14 @@ func (s *httpdServer) initializeRouter() {
 		router.Put(userPwdPath, changeUserPassword)
 		router.With(checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).Get(userPublicKeysPath, getUserPublicKeys)
 		router.With(checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).Put(userPublicKeysPath, setUserPublicKeys)
-		router.Get(userReadFolderPath, readUserFolder)
-		router.Get(userGetFilePath, getUserFile)
+		router.Get(userFolderPath, readUserFolder)
+		router.Post(userFolderPath, createUserDir)
+		router.Patch(userFolderPath, renameUserDir)
+		router.Delete(userFolderPath, deleteUserDir)
+		router.Get(userFilePath, getUserFile)
+		router.Post(userFilePath, uploadUserFiles)
+		router.Patch(userFilePath, renameUserFile)
+		router.Delete(userFilePath, deleteUserFile)
 		router.Post(userStreamZipPath, getUserFilesAsZipStream)
 	})
 

+ 2 - 1
sftpgo.json

@@ -202,7 +202,8 @@
     "certificate_key_file": "",
     "ca_certificates": [],
     "ca_revocation_lists": [],
-    "signing_passphrase": ""
+    "signing_passphrase": "",
+    "max_upload_file_size": 1048576000
   },
   "telemetry": {
     "bind_port": 10000,

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini