浏览代码

REST API: add an option to create missing dirs

Nicola Murino 3 年之前
父节点
当前提交
ced73ed04e
共有 13 个文件被更改,包括 290 次插入24 次删除
  1. 50 1
      common/connection.go
  2. 77 0
      common/connection_test.go
  3. 46 0
      common/protocol_test.go
  4. 0 1
      ftpd/handler.go
  5. 3 3
      go.mod
  6. 4 8
      go.sum
  7. 18 0
      httpd/api_http_user.go
  8. 9 4
      httpd/api_utils.go
  9. 0 1
      httpd/handler.go
  10. 65 3
      httpd/httpd_test.go
  11. 18 0
      openapi/openapi.yaml
  12. 0 2
      sftpd/handler.go
  13. 0 1
      webdavd/handler.go

+ 50 - 1
common/connection.go

@@ -245,6 +245,35 @@ func (c *BaseConnection) ListDir(virtualPath string) ([]os.FileInfo, error) {
 	return c.User.AddVirtualDirs(files, virtualPath), nil
 }
 
+// CheckParentDirs tries to create the specified directory and any missing parent dirs
+func (c *BaseConnection) CheckParentDirs(virtualPath string) error {
+	fs, err := c.User.GetFilesystemForPath(virtualPath, "")
+	if err != nil {
+		return err
+	}
+	if fs.HasVirtualFolders() {
+		return nil
+	}
+	if _, err := c.DoStat(virtualPath, 0); !c.IsNotExistError(err) {
+		return err
+	}
+	dirs := util.GetDirsForVirtualPath(virtualPath)
+	for idx := len(dirs) - 1; idx >= 0; idx-- {
+		fs, err = c.User.GetFilesystemForPath(dirs[idx], "")
+		if err != nil {
+			return err
+		}
+		if fs.HasVirtualFolders() {
+			continue
+		}
+		if err = c.createDirIfMissing(dirs[idx]); err != nil {
+			return fmt.Errorf("unable to check/create missing parent dir %#v for virtual path %#v: %w",
+				dirs[idx], virtualPath, err)
+		}
+	}
+	return nil
+}
+
 // CreateDir creates a new directory at the specified fsPath
 func (c *BaseConnection) CreateDir(virtualPath string) error {
 	if !c.User.HasPerm(dataprovider.PermCreateDirs, path.Dir(virtualPath)) {
@@ -507,7 +536,7 @@ func (c *BaseConnection) DoStat(virtualPath string, mode int) (os.FileInfo, erro
 		info, err = fs.Stat(c.getRealFsPath(fsPath))
 	}
 	if err != nil {
-		c.Log(logger.LevelDebug, "stat error for path %#v: %+v", virtualPath, err)
+		c.Log(logger.LevelError, "stat error for path %#v: %+v", virtualPath, err)
 		return info, c.GetFsError(fs, err)
 	}
 	if vfs.IsCryptOsFs(fs) {
@@ -516,6 +545,14 @@ func (c *BaseConnection) DoStat(virtualPath string, mode int) (os.FileInfo, erro
 	return info, nil
 }
 
+func (c *BaseConnection) createDirIfMissing(name string) error {
+	_, err := c.DoStat(name, 0)
+	if c.IsNotExistError(err) {
+		return c.CreateDir(name)
+	}
+	return err
+}
+
 func (c *BaseConnection) ignoreSetStat(fs vfs.Fs) bool {
 	if Config.SetstatMode == 1 {
 		return true
@@ -1089,6 +1126,18 @@ func (c *BaseConnection) updateQuotaAfterRename(fs vfs.Fs, virtualSourcePath, vi
 	return nil
 }
 
+// IsNotExistError returns true if the specified fs error is not exist for the connection protocol
+func (c *BaseConnection) IsNotExistError(err error) bool {
+	switch c.protocol {
+	case ProtocolSFTP:
+		return errors.Is(err, sftp.ErrSSHFxNoSuchFile)
+	case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolHTTPShare, ProtocolDataRetention:
+		return errors.Is(err, os.ErrNotExist)
+	default:
+		return errors.Is(err, ErrNotExist)
+	}
+}
+
 // GetPermissionDeniedError returns an appropriate permission denied error for the connection protocol
 func (c *BaseConnection) GetPermissionDeniedError() error {
 	switch c.protocol {

+ 77 - 0
common/connection_test.go

@@ -9,9 +9,11 @@ import (
 	"time"
 
 	"github.com/pkg/sftp"
+	"github.com/rs/xid"
 	"github.com/stretchr/testify/assert"
 
 	"github.com/drakkan/sftpgo/v2/dataprovider"
+	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/vfs"
 )
@@ -306,6 +308,8 @@ func TestErrorsMapping(t *testing.T) {
 		}
 		err = conn.GetQuotaExceededError()
 		assert.True(t, conn.IsQuotaExceededError(err))
+		err = conn.GetNotExistError()
+		assert.True(t, conn.IsNotExistError(err))
 		err = conn.GetFsError(fs, nil)
 		assert.NoError(t, err)
 		err = conn.GetOpUnsupportedError()
@@ -368,3 +372,76 @@ func TestMaxWriteSize(t *testing.T) {
 	assert.EqualError(t, err, ErrOpUnsupported.Error())
 	assert.Equal(t, int64(0), size)
 }
+
+func TestCheckParentDirsErrors(t *testing.T) {
+	permissions := make(map[string][]string)
+	permissions["/"] = []string{dataprovider.PermAny}
+	user := dataprovider.User{
+		BaseUser: sdk.BaseUser{
+			Username:    userTestUsername,
+			Permissions: permissions,
+			HomeDir:     filepath.Clean(os.TempDir()),
+		},
+		FsConfig: vfs.Filesystem{
+			Provider: sdk.CryptedFilesystemProvider,
+		},
+	}
+	c := NewBaseConnection(xid.New().String(), ProtocolSFTP, "", "", user)
+	err := c.CheckParentDirs("/a/dir")
+	assert.Error(t, err)
+
+	user.FsConfig.Provider = sdk.LocalFilesystemProvider
+	user.VirtualFolders = nil
+	user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
+		BaseVirtualFolder: vfs.BaseVirtualFolder{
+			FsConfig: vfs.Filesystem{
+				Provider: sdk.CryptedFilesystemProvider,
+			},
+		},
+		VirtualPath: "/vdir",
+	})
+	user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
+		BaseVirtualFolder: vfs.BaseVirtualFolder{
+			MappedPath: filepath.Clean(os.TempDir()),
+		},
+		VirtualPath: "/vdir/sub",
+	})
+	c = NewBaseConnection(xid.New().String(), ProtocolSFTP, "", "", user)
+	err = c.CheckParentDirs("/vdir/sub/dir")
+	assert.Error(t, err)
+
+	user = dataprovider.User{
+		BaseUser: sdk.BaseUser{
+			Username:    userTestUsername,
+			Permissions: permissions,
+			HomeDir:     filepath.Clean(os.TempDir()),
+		},
+		FsConfig: vfs.Filesystem{
+			Provider: sdk.S3FilesystemProvider,
+			S3Config: vfs.S3FsConfig{
+				S3FsConfig: sdk.S3FsConfig{
+					Bucket:       "buck",
+					Region:       "us-east-1",
+					AccessKey:    "key",
+					AccessSecret: kms.NewPlainSecret("s3secret"),
+				},
+			},
+		},
+	}
+	c = NewBaseConnection(xid.New().String(), ProtocolSFTP, "", "", user)
+	err = c.CheckParentDirs("/a/dir")
+	assert.NoError(t, err)
+
+	user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
+		BaseVirtualFolder: vfs.BaseVirtualFolder{
+			MappedPath: filepath.Clean(os.TempDir()),
+		},
+		VirtualPath: "/local/dir",
+	})
+
+	c = NewBaseConnection(xid.New().String(), ProtocolSFTP, "", "", user)
+	err = c.CheckParentDirs("/local/dir/sub-dir")
+	assert.NoError(t, err)
+	err = os.RemoveAll(filepath.Join(os.TempDir(), "sub-dir"))
+	assert.NoError(t, err)
+}

+ 46 - 0
common/protocol_test.go

@@ -23,6 +23,7 @@ import (
 	"github.com/pkg/sftp"
 	"github.com/pquerna/otp"
 	"github.com/pquerna/otp/totp"
+	"github.com/rs/xid"
 	"github.com/rs/zerolog"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -373,6 +374,51 @@ func TestChtimesOpenHandle(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestCheckParentDirs(t *testing.T) {
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+
+	testDir := "/path/to/sub/dir"
+	conn, client, err := getSftpClient(user)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+
+		_, err = client.Stat(testDir)
+		assert.ErrorIs(t, err, os.ErrNotExist)
+		c := common.NewBaseConnection(xid.New().String(), common.ProtocolSFTP, "", "", user)
+		err = c.CheckParentDirs(testDir)
+		assert.NoError(t, err)
+		_, err = client.Stat(testDir)
+		assert.NoError(t, err)
+		err = c.CheckParentDirs(testDir)
+		assert.NoError(t, err)
+	}
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+
+	u := getTestUser()
+	u.Permissions["/"] = []string{dataprovider.PermUpload, dataprovider.PermListItems, dataprovider.PermDownload}
+	user, _, err = httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	conn, client, err = getSftpClient(user)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+
+		c := common.NewBaseConnection(xid.New().String(), common.ProtocolSFTP, "", "", user)
+		err = c.CheckParentDirs(testDir)
+		assert.ErrorIs(t, err, sftp.ErrSSHFxPermissionDenied)
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestPermissionErrors(t *testing.T) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)

+ 0 - 1
ftpd/handler.go

@@ -147,7 +147,6 @@ func (c *Connection) Stat(name string) (os.FileInfo, error) {
 
 	fi, err := c.DoStat(name, 0)
 	if err != nil {
-		c.Log(logger.LevelDebug, "error running stat on path %#v: %+v", name, err)
 		return nil, err
 	}
 	return fi, nil

+ 3 - 3
go.mod

@@ -33,12 +33,12 @@ require (
 	github.com/minio/sio v0.3.0
 	github.com/otiai10/copy v1.7.0
 	github.com/pires/go-proxyproto v0.6.1
-	github.com/pkg/sftp v1.13.4
+	github.com/pkg/sftp v1.13.5-0.20211217081921-1849af66afae
 	github.com/pquerna/otp v1.3.0
 	github.com/prometheus/client_golang v1.11.0
 	github.com/rs/cors v1.8.0
 	github.com/rs/xid v1.3.0
-	github.com/rs/zerolog v1.26.1
+	github.com/rs/zerolog v1.26.2-0.20211217020337-0c8d3c0b10c3
 	github.com/shirou/gopsutil/v3 v3.21.11
 	github.com/spf13/afero v1.6.0
 	github.com/spf13/cobra v1.3.0
@@ -51,7 +51,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-20211215165025-cf75a172585e
+	golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3
 	golang.org/x/net v0.0.0-20211209124913-491a49abca63
 	golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e
 	golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11

+ 4 - 8
go.sum

@@ -682,8 +682,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
 github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
-github.com/pkg/sftp v1.13.4 h1:Lb0RYJCmgUcBgZosfoi9Y9sbl6+LJgOIgk/2Y4YjMFg=
-github.com/pkg/sftp v1.13.4/go.mod h1:LzqnAvaD5TWeNBsZpfKxSYn1MbjWwOsCIAFFJbpIsK8=
+github.com/pkg/sftp v1.13.5-0.20211217081921-1849af66afae h1:J8MHmz3LSjRtoR4SKiPq8BNo3DacJl5kQRjJeWilkUI=
+github.com/pkg/sftp v1.13.5-0.20211217081921-1849af66afae/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
@@ -726,8 +726,8 @@ github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4=
 github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
 github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
 github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
-github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc=
-github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc=
+github.com/rs/zerolog v1.26.2-0.20211217020337-0c8d3c0b10c3 h1:TwTFgwG2b3h0GNPv3o9+Thu9n0lrIj2t+IsWHY/DbOE=
+github.com/rs/zerolog v1.26.2-0.20211217020337-0c8d3c0b10c3/go.mod h1:eHQZiDw9MQWpDKmOvvJAV5O5us43b8IMRafp/wTdspo=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
@@ -800,7 +800,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
 github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
 github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
@@ -973,7 +972,6 @@ golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -984,7 +982,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1083,7 +1080,6 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

+ 18 - 0
httpd/api_http_user.go

@@ -86,6 +86,12 @@ func createUserDir(w http.ResponseWriter, r *http.Request) {
 	defer common.Connections.Remove(connection.GetID())
 
 	name := util.CleanPath(r.URL.Query().Get("path"))
+	if getBoolQueryParam(r, "mkdir_parents") {
+		if err = connection.CheckParentDirs(path.Dir(name)); err != nil {
+			sendAPIResponse(w, r, err, "Error checking parent directories", getMappedStatusCode(err))
+			return
+		}
+	}
 	err = connection.CreateDir(name)
 	if err != nil {
 		sendAPIResponse(w, r, err, fmt.Sprintf("Unable to create directory %#v", name), getMappedStatusCode(err))
@@ -224,6 +230,12 @@ func uploadUserFile(w http.ResponseWriter, r *http.Request) {
 	defer common.Connections.Remove(connection.GetID())
 
 	filePath := util.CleanPath(r.URL.Query().Get("path"))
+	if getBoolQueryParam(r, "mkdir_parents") {
+		if err = connection.CheckParentDirs(path.Dir(filePath)); err != nil {
+			sendAPIResponse(w, r, err, "Error checking parent directories", getMappedStatusCode(err))
+			return
+		}
+	}
 	doUploadFile(w, r, connection, filePath) //nolint:errcheck
 }
 
@@ -278,6 +290,12 @@ func uploadUserFiles(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, nil, "No files uploaded!", http.StatusBadRequest)
 		return
 	}
+	if getBoolQueryParam(r, "mkdir_parents") {
+		if err = connection.CheckParentDirs(parentDir); err != nil {
+			sendAPIResponse(w, r, err, "Error checking parent directories", getMappedStatusCode(err))
+			return
+		}
+	}
 	doUploadFiles(w, r, connection, parentDir, files)
 }
 

+ 9 - 4
httpd/api_utils.go

@@ -91,14 +91,15 @@ func getRespStatus(err error) int {
 	return http.StatusInternalServerError
 }
 
+// mappig between fs errors for HTTP protocol and HTTP response status codes
 func getMappedStatusCode(err error) int {
 	var statusCode int
-	switch err {
-	case os.ErrPermission:
+	switch {
+	case errors.Is(err, os.ErrPermission):
 		statusCode = http.StatusForbidden
-	case os.ErrNotExist:
+	case errors.Is(err, os.ErrNotExist):
 		statusCode = http.StatusNotFound
-	case common.ErrQuotaExceeded:
+	case errors.Is(err, common.ErrQuotaExceeded):
 		statusCode = http.StatusRequestEntityTooLarge
 	default:
 		statusCode = http.StatusInternalServerError
@@ -128,6 +129,10 @@ func getCommaSeparatedQueryParam(r *http.Request, key string) []string {
 	return util.RemoveDuplicates(result)
 }
 
+func getBoolQueryParam(r *http.Request, param string) bool {
+	return r.URL.Query().Get(param) == "true"
+}
+
 func handleCloseConnection(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	connectionID := getURLParam(r, "connectionID")

+ 0 - 1
httpd/handler.go

@@ -68,7 +68,6 @@ func (c *Connection) Stat(name string, mode int) (os.FileInfo, error) {
 
 	fi, err := c.DoStat(name, mode)
 	if err != nil {
-		c.Log(logger.LevelDebug, "error running stat on path %#v: %+v", name, err)
 		return nil, err
 	}
 	return fi, err

+ 65 - 3
httpd/httpd_test.go

@@ -10179,6 +10179,18 @@ func TestWebDirsAPI(t *testing.T) {
 	if assert.Len(t, contents, 1) {
 		assert.Equal(t, testDir, contents[0]["name"])
 	}
+	// create a dir with missing parents
+	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?path="+url.QueryEscape(path.Join("/sub/dir", testDir)), nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+	// setting the mkdir_parents param will work
+	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?mkdir_parents=true&path="+url.QueryEscape(path.Join("/sub/dir", testDir)), nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
 	// rename the dir
 	req, err = http.NewRequest(http.MethodPatch, userDirsPath+"?path="+testDir+"&target="+testDir+"new", nil)
 	assert.NoError(t, err)
@@ -10271,6 +10283,17 @@ func TestWebUploadSingleFile(t *testing.T) {
 	if assert.NoError(t, err) {
 		assert.InDelta(t, util.GetTimeAsMsSinceEpoch(time.Now()), util.GetTimeAsMsSinceEpoch(info.ModTime()), float64(3000))
 	}
+	// upload to a missing dir will fail without the mkdir_parents param
+	req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?path="+url.QueryEscape("/subdir/file.txt"), bytes.NewBuffer(content))
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+	req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?mkdir_parents=true&path="+url.QueryEscape("/subdir/file.txt"), bytes.NewBuffer(content))
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
 
 	metadataReq := make(map[string]int64)
 	metadataReq["modification_time"] = util.GetTimeAsMsSinceEpoch(modTime)
@@ -10424,6 +10447,24 @@ func TestWebFilesAPI(t *testing.T) {
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusCreated, rr)
+	// upload to a missing subdir will fail without the mkdir_parents param
+	_, err = reader.Seek(0, io.SeekStart)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath+"?path="+url.QueryEscape("/sub/"+testDir), reader)
+	assert.NoError(t, err)
+	req.Header.Add("Content-Type", writer.FormDataContentType())
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+	_, err = reader.Seek(0, io.SeekStart)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath+"?mkdir_parents=true&path="+url.QueryEscape("/sub/"+testDir), reader)
+	assert.NoError(t, err)
+	req.Header.Add("Content-Type", writer.FormDataContentType())
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+
 	req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
@@ -10432,7 +10473,7 @@ func TestWebFilesAPI(t *testing.T) {
 	contents = nil
 	err = json.NewDecoder(rr.Body).Decode(&contents)
 	assert.NoError(t, err)
-	assert.Len(t, contents, 3)
+	assert.Len(t, contents, 4)
 	req, err = http.NewRequest(http.MethodGet, userDirsPath+"?path="+testDir, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, webAPIToken)
@@ -10579,7 +10620,28 @@ func TestWebUploadErrors(t *testing.T) {
 	setBearerForReq(req, webAPIToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
-
+	// we cannot create dirs in sub2
+	_, err = reader.Seek(0, io.SeekStart)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, userFilesPath+"?mkdir_parents=true&path="+url.QueryEscape("/sub2/dir"), reader)
+	assert.NoError(t, err)
+	req.Header.Add("Content-Type", writer.FormDataContentType())
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "unable to check/create missing parent dir")
+	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?mkdir_parents=true&path="+url.QueryEscape("/sub2/dir/test"), nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "Error checking parent directories")
+	req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?mkdir_parents=true&path="+url.QueryEscape("/sub2/dir1/file.txt"), bytes.NewBuffer([]byte("")))
+	assert.NoError(t, err)
+	setBearerForReq(req, webAPIToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "Error checking parent directories")
 	// create a dir and try to overwrite it with a file
 	req, err = http.NewRequest(http.MethodPost, userDirsPath+"?path=file.zip", nil)
 	assert.NoError(t, err)
@@ -11026,7 +11088,7 @@ func TestWebUploadMultipartFormReadError(t *testing.T) {
 	req.Header.Add("Content-Type", "multipart/form-data")
 	setBearerForReq(req, webAPIToken)
 	rr := executeRequest(req)
-	checkResponseCode(t, http.StatusInternalServerError, rr)
+	checkResponseCode(t, http.StatusNotFound, rr)
 	assert.Contains(t, rr.Body.String(), "Unable to read uploaded file")
 
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)

+ 18 - 0
openapi/openapi.yaml

@@ -3543,6 +3543,12 @@ paths:
           schema:
             type: string
           required: true
+        - in: query
+          name: mkdir_parents
+          description: Create parent directories if they do not exist?
+          schema:
+            type: boolean
+          required: false
       responses:
         '201':
           description: successful operation
@@ -3729,6 +3735,12 @@ paths:
           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
+        - in: query
+          name: mkdir_parents
+          description: Create parent directories if they do not exist?
+          schema:
+            type: boolean
+          required: false
       requestBody:
         content:
           multipart/form-data:
@@ -3848,6 +3860,12 @@ paths:
           schema:
             type: string
           required: true
+        - in: query
+          name: mkdir_parents
+          description: Create parent directories if they do not exist?
+          schema:
+            type: boolean
+          required: false
         - in: header
           name: X-SFTPGO-MTIME
           schema:

+ 0 - 2
sftpd/handler.go

@@ -216,7 +216,6 @@ func (c *Connection) Filelist(request *sftp.Request) (sftp.ListerAt, error) {
 
 		s, err := c.DoStat(request.Filepath, 0)
 		if err != nil {
-			c.Log(logger.LevelDebug, "error running stat on path %#v: %+v", request.Filepath, err)
 			return nil, err
 		}
 
@@ -258,7 +257,6 @@ func (c *Connection) Lstat(request *sftp.Request) (sftp.ListerAt, error) {
 
 	s, err := c.DoStat(request.Filepath, 1)
 	if err != nil {
-		c.Log(logger.LevelDebug, "error running lstat on path %#v: %+v", request.Filepath, err)
 		return nil, err
 	}
 

+ 0 - 1
webdavd/handler.go

@@ -87,7 +87,6 @@ func (c *Connection) Stat(ctx context.Context, name string) (os.FileInfo, error)
 
 	fi, err := c.DoStat(name, 0)
 	if err != nil {
-		c.Log(logger.LevelDebug, "error running stat on path %#v: %+v", name, err)
 		return nil, err
 	}
 	return fi, err