Browse Source

web client: add share mode read/write

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 3 years ago
parent
commit
1e0b3a2a8c

+ 3 - 1
cmd/genman.go

@@ -1,7 +1,9 @@
 package cmd
 package cmd
 
 
 import (
 import (
+	"errors"
 	"fmt"
 	"fmt"
+	"io/fs"
 	"os"
 	"os"
 
 
 	"github.com/rs/zerolog"
 	"github.com/rs/zerolog"
@@ -25,7 +27,7 @@ current directory.
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
 			logger.DisableLogger()
 			logger.DisableLogger()
 			logger.EnableConsoleLogger(zerolog.DebugLevel)
 			logger.EnableConsoleLogger(zerolog.DebugLevel)
-			if _, err := os.Stat(manDir); os.IsNotExist(err) {
+			if _, err := os.Stat(manDir); errors.Is(err, fs.ErrNotExist) {
 				err = os.MkdirAll(manDir, os.ModePerm)
 				err = os.MkdirAll(manDir, os.ModePerm)
 				if err != nil {
 				if err != nil {
 					logger.WarnToConsole("Unable to generate man page files: %v", err)
 					logger.WarnToConsole("Unable to generate man page files: %v", err)

+ 8 - 5
dataprovider/share.go

@@ -21,6 +21,7 @@ type ShareScope int
 const (
 const (
 	ShareScopeRead ShareScope = iota + 1
 	ShareScopeRead ShareScope = iota + 1
 	ShareScopeWrite
 	ShareScopeWrite
+	ShareScopeReadWrite
 )
 )
 
 
 const (
 const (
@@ -64,10 +65,12 @@ type Share struct {
 // Used in web pages
 // Used in web pages
 func (s *Share) GetScopeAsString() string {
 func (s *Share) GetScopeAsString() string {
 	switch s.Scope {
 	switch s.Scope {
-	case ShareScopeRead:
-		return "Read"
-	default:
+	case ShareScopeWrite:
 		return "Write"
 		return "Write"
+	case ShareScopeReadWrite:
+		return "Read/Write"
+	default:
+		return "Read"
 	}
 	}
 }
 }
 
 
@@ -194,7 +197,7 @@ func (s *Share) validatePaths() error {
 		s.Paths[idx] = util.CleanPath(s.Paths[idx])
 		s.Paths[idx] = util.CleanPath(s.Paths[idx])
 	}
 	}
 	s.Paths = util.RemoveDuplicates(s.Paths)
 	s.Paths = util.RemoveDuplicates(s.Paths)
-	if s.Scope == ShareScopeWrite && len(s.Paths) != 1 {
+	if s.Scope >= ShareScopeWrite && len(s.Paths) != 1 {
 		return util.NewValidationError("the write share scope requires exactly one path")
 		return util.NewValidationError("the write share scope requires exactly one path")
 	}
 	}
 	// check nested paths
 	// check nested paths
@@ -220,7 +223,7 @@ func (s *Share) validate() error {
 	if s.Name == "" {
 	if s.Name == "" {
 		return util.NewValidationError("name is mandatory")
 		return util.NewValidationError("name is mandatory")
 	}
 	}
-	if s.Scope != ShareScopeRead && s.Scope != ShareScopeWrite {
+	if s.Scope < ShareScopeRead || s.Scope > ShareScopeReadWrite {
 		return util.NewValidationError(fmt.Sprintf("invalid scope: %v", s.Scope))
 		return util.NewValidationError(fmt.Sprintf("invalid scope: %v", s.Scope))
 	}
 	}
 	if err := s.validatePaths(); err != nil {
 	if err := s.validatePaths(); err != nil {

+ 3 - 1
ftpd/ftpd_test.go

@@ -6,8 +6,10 @@ import (
 	"crypto/tls"
 	"crypto/tls"
 	"encoding/hex"
 	"encoding/hex"
 	"encoding/json"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
+	"io/fs"
 	"net"
 	"net"
 	"net/http"
 	"net/http"
 	"os"
 	"os"
@@ -3463,7 +3465,7 @@ func getExitCodeScriptContent(exitCode int) []byte {
 
 
 func createTestFile(path string, size int64) error {
 func createTestFile(path string, size int64) error {
 	baseDir := filepath.Dir(path)
 	baseDir := filepath.Dir(path)
-	if _, err := os.Stat(baseDir); os.IsNotExist(err) {
+	if _, err := os.Stat(baseDir); errors.Is(err, fs.ErrNotExist) {
 		err = os.MkdirAll(baseDir, os.ModePerm)
 		err = os.MkdirAll(baseDir, os.ModePerm)
 		if err != nil {
 		if err != nil {
 			return err
 			return err

+ 3 - 1
ftpd/internal_test.go

@@ -3,7 +3,9 @@ package ftpd
 import (
 import (
 	"crypto/tls"
 	"crypto/tls"
 	"crypto/x509"
 	"crypto/x509"
+	"errors"
 	"fmt"
 	"fmt"
+	"io/fs"
 	"net"
 	"net"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
@@ -735,7 +737,7 @@ func TestAVBLErrors(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	_, err = connection.GetAvailableSpace("/missing-path")
 	_, err = connection.GetAvailableSpace("/missing-path")
 	assert.Error(t, err)
 	assert.Error(t, err)
-	assert.True(t, os.IsNotExist(err))
+	assert.True(t, errors.Is(err, fs.ErrNotExist))
 }
 }
 
 
 func TestUploadOverwriteErrors(t *testing.T) {
 func TestUploadOverwriteErrors(t *testing.T) {

+ 1 - 1
httpd/api_http_user.go

@@ -290,7 +290,7 @@ func doUploadFiles(w http.ResponseWriter, r *http.Request, connection *Connectio
 		}
 		}
 		defer file.Close()
 		defer file.Close()
 
 
-		filePath := path.Join(parentDir, f.Filename)
+		filePath := path.Join(parentDir, path.Base(util.CleanPath(f.Filename)))
 		writer, err := connection.getFileWriter(filePath)
 		writer, err := connection.getFileWriter(filePath)
 		if err != nil {
 		if err != nil {
 			sendAPIResponse(w, r, err, fmt.Sprintf("Unable to write file %#v", f.Filename), getMappedStatusCode(err))
 			sendAPIResponse(w, r, err, fmt.Sprintf("Unable to write file %#v", f.Filename), getMappedStatusCode(err))

+ 37 - 16
httpd/api_shares.go

@@ -153,7 +153,8 @@ func deleteShare(w http.ResponseWriter, r *http.Request) {
 
 
 func (s *httpdServer) readBrowsableShareContents(w http.ResponseWriter, r *http.Request) {
 func (s *httpdServer) readBrowsableShareContents(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	share, connection, err := s.checkPublicShare(w, r, dataprovider.ShareScopeRead, false)
+	validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite}
+	share, connection, err := s.checkPublicShare(w, r, validScopes, false)
 	if err != nil {
 	if err != nil {
 		return
 		return
 	}
 	}
@@ -183,7 +184,8 @@ func (s *httpdServer) readBrowsableShareContents(w http.ResponseWriter, r *http.
 
 
 func (s *httpdServer) downloadBrowsableSharedFile(w http.ResponseWriter, r *http.Request) {
 func (s *httpdServer) downloadBrowsableSharedFile(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	share, connection, err := s.checkPublicShare(w, r, dataprovider.ShareScopeRead, false)
+	validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite}
+	share, connection, err := s.checkPublicShare(w, r, validScopes, false)
 	if err != nil {
 	if err != nil {
 		return
 		return
 	}
 	}
@@ -232,7 +234,8 @@ func (s *httpdServer) downloadBrowsableSharedFile(w http.ResponseWriter, r *http
 
 
 func (s *httpdServer) downloadFromShare(w http.ResponseWriter, r *http.Request) {
 func (s *httpdServer) downloadFromShare(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	share, connection, err := s.checkPublicShare(w, r, dataprovider.ShareScopeRead, false)
+	validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite}
+	share, connection, err := s.checkPublicShare(w, r, validScopes, false)
 	if err != nil {
 	if err != nil {
 		return
 		return
 	}
 	}
@@ -264,7 +267,7 @@ func (s *httpdServer) downloadFromShare(w http.ResponseWriter, r *http.Request)
 			connection.Log(logger.LevelInfo, "denying share read due to quota limits")
 			connection.Log(logger.LevelInfo, "denying share read due to quota limits")
 			sendAPIResponse(w, r, err, "", getMappedStatusCode(err))
 			sendAPIResponse(w, r, err, "", getMappedStatusCode(err))
 		}
 		}
-		w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"share-%v.zip\"", share.ShareID))
+		w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"share-%v.zip\"", share.Name))
 		renderCompressedFiles(w, connection, "/", share.Paths, &share)
 		renderCompressedFiles(w, connection, "/", share.Paths, &share)
 		return
 		return
 	}
 	}
@@ -287,12 +290,17 @@ func (s *httpdServer) uploadFileToShare(w http.ResponseWriter, r *http.Request)
 		r.Body = http.MaxBytesReader(w, r.Body, maxUploadFileSize)
 		r.Body = http.MaxBytesReader(w, r.Body, maxUploadFileSize)
 	}
 	}
 	name := getURLParam(r, "name")
 	name := getURLParam(r, "name")
-	share, connection, err := s.checkPublicShare(w, r, dataprovider.ShareScopeWrite, false)
+	validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeWrite, dataprovider.ShareScopeReadWrite}
+	share, connection, err := s.checkPublicShare(w, r, validScopes, false)
 	if err != nil {
 	if err != nil {
 		return
 		return
 	}
 	}
-	filePath := path.Join(share.Paths[0], name)
-	if path.Dir(filePath) != share.Paths[0] {
+	filePath := util.CleanPath(path.Join(share.Paths[0], name))
+	expectedPrefix := share.Paths[0]
+	if !strings.HasSuffix(expectedPrefix, "/") {
+		expectedPrefix += "/"
+	}
+	if !strings.HasPrefix(filePath, expectedPrefix) {
 		sendAPIResponse(w, r, err, "Uploading outside the share is not allowed", http.StatusForbidden)
 		sendAPIResponse(w, r, err, "Uploading outside the share is not allowed", http.StatusForbidden)
 		return
 		return
 	}
 	}
@@ -312,7 +320,8 @@ func (s *httpdServer) uploadFilesToShare(w http.ResponseWriter, r *http.Request)
 	if maxUploadFileSize > 0 {
 	if maxUploadFileSize > 0 {
 		r.Body = http.MaxBytesReader(w, r.Body, maxUploadFileSize)
 		r.Body = http.MaxBytesReader(w, r.Body, maxUploadFileSize)
 	}
 	}
-	share, connection, err := s.checkPublicShare(w, r, dataprovider.ShareScopeWrite, false)
+	validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeWrite, dataprovider.ShareScopeReadWrite}
+	share, connection, err := s.checkPublicShare(w, r, validScopes, false)
 	if err != nil {
 	if err != nil {
 		return
 		return
 	}
 	}
@@ -361,7 +370,7 @@ func (s *httpdServer) uploadFilesToShare(w http.ResponseWriter, r *http.Request)
 	}
 	}
 }
 }
 
 
-func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, shareShope dataprovider.ShareScope,
+func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, validScopes []dataprovider.ShareScope,
 	isWebClient bool,
 	isWebClient bool,
 ) (dataprovider.Share, *Connection, error) {
 ) (dataprovider.Share, *Connection, error) {
 	renderError := func(err error, message string, statusCode int) {
 	renderError := func(err error, message string, statusCode int) {
@@ -382,7 +391,7 @@ func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, s
 		renderError(err, "", statusCode)
 		renderError(err, "", statusCode)
 		return share, nil, err
 		return share, nil, err
 	}
 	}
-	if share.Scope != shareShope {
+	if !util.Contains(validScopes, share.Scope) {
 		renderError(nil, "Invalid share scope", http.StatusForbidden)
 		renderError(nil, "Invalid share scope", http.StatusForbidden)
 		return share, nil, errors.New("invalid share scope")
 		return share, nil, errors.New("invalid share scope")
 	}
 	}
@@ -406,16 +415,11 @@ func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, s
 			return share, nil, dataprovider.ErrInvalidCredentials
 			return share, nil, dataprovider.ErrInvalidCredentials
 		}
 		}
 	}
 	}
-	user, err := dataprovider.GetUserWithGroupSettings(share.Username)
+	user, err := getUserForShare(share)
 	if err != nil {
 	if err != nil {
 		renderError(err, "", getRespStatus(err))
 		renderError(err, "", getRespStatus(err))
 		return share, nil, err
 		return share, nil, err
 	}
 	}
-	if user.MustSetSecondFactorForProtocol(common.ProtocolHTTP) {
-		err := util.NewMethodDisabledError("two-factor authentication requirements not met")
-		renderError(err, "", getRespStatus(err))
-		return share, nil, err
-	}
 	connID := xid.New().String()
 	connID := xid.New().String()
 	connection := &Connection{
 	connection := &Connection{
 		BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTPShare, util.GetHTTPLocalAddress(r),
 		BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTPShare, util.GetHTTPLocalAddress(r),
@@ -426,6 +430,23 @@ func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, s
 	return share, connection, nil
 	return share, connection, nil
 }
 }
 
 
+func getUserForShare(share dataprovider.Share) (dataprovider.User, error) {
+	user, err := dataprovider.GetUserWithGroupSettings(share.Username)
+	if err != nil {
+		return user, err
+	}
+	if !user.CanManageShares() {
+		return user, util.NewRecordNotFoundError("this share does not exist")
+	}
+	if share.Password == "" && util.Contains(user.Filters.WebClient, sdk.WebClientShareNoPasswordDisabled) {
+		return user, fmt.Errorf("sharing without a password was disabled: %w", os.ErrPermission)
+	}
+	if user.MustSetSecondFactorForProtocol(common.ProtocolHTTP) {
+		return user, util.NewMethodDisabledError("two-factor authentication requirements not met")
+	}
+	return user, nil
+}
+
 func validateBrowsableShare(share dataprovider.Share, connection *Connection) error {
 func validateBrowsableShare(share dataprovider.Share, connection *Connection) error {
 	if len(share.Paths) != 1 {
 	if len(share.Paths) != 1 {
 		return util.NewValidationError("a share with multiple paths is not browsable")
 		return util.NewValidationError("a share with multiple paths is not browsable")

+ 13 - 4
httpd/api_utils.go

@@ -6,6 +6,7 @@ import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
+	"io/fs"
 	"mime"
 	"mime"
 	"net/http"
 	"net/http"
 	"net/url"
 	"net/url"
@@ -79,10 +80,10 @@ func getRespStatus(err error) int {
 	if _, ok := err.(*util.RecordNotFoundError); ok {
 	if _, ok := err.(*util.RecordNotFoundError); ok {
 		return http.StatusNotFound
 		return http.StatusNotFound
 	}
 	}
-	if os.IsNotExist(err) {
+	if errors.Is(err, fs.ErrNotExist) {
 		return http.StatusBadRequest
 		return http.StatusBadRequest
 	}
 	}
-	if os.IsPermission(err) || errors.Is(err, dataprovider.ErrLoginNotAllowedFromIP) {
+	if errors.Is(err, fs.ErrPermission) || errors.Is(err, dataprovider.ErrLoginNotAllowedFromIP) {
 		return http.StatusForbidden
 		return http.StatusForbidden
 	}
 	}
 	if errors.Is(err, plugin.ErrNoSearcher) || errors.Is(err, dataprovider.ErrNotImplemented) {
 	if errors.Is(err, plugin.ErrNoSearcher) || errors.Is(err, dataprovider.ErrNotImplemented) {
@@ -241,7 +242,11 @@ func addZipEntry(wr *zip.Writer, conn *Connection, entryPath, baseDir string) er
 		return err
 		return err
 	}
 	}
 	if info.IsDir() {
 	if info.IsDir() {
-		_, err := wr.Create(getZipEntryName(entryPath, baseDir) + "/")
+		_, err := wr.CreateHeader(&zip.FileHeader{
+			Name:     getZipEntryName(entryPath, baseDir) + "/",
+			Method:   zip.Deflate,
+			Modified: info.ModTime(),
+		})
 		if err != nil {
 		if err != nil {
 			conn.Log(logger.LevelDebug, "unable to create zip entry %#v: %v", entryPath, err)
 			conn.Log(logger.LevelDebug, "unable to create zip entry %#v: %v", entryPath, err)
 			return err
 			return err
@@ -271,7 +276,11 @@ func addZipEntry(wr *zip.Writer, conn *Connection, entryPath, baseDir string) er
 	}
 	}
 	defer reader.Close()
 	defer reader.Close()
 
 
-	f, err := wr.Create(getZipEntryName(entryPath, baseDir))
+	f, err := wr.CreateHeader(&zip.FileHeader{
+		Name:     getZipEntryName(entryPath, baseDir),
+		Method:   zip.Deflate,
+		Modified: info.ModTime(),
+	})
 	if err != nil {
 	if err != nil {
 		conn.Log(logger.LevelDebug, "unable to create zip entry %#v: %v", entryPath, err)
 		conn.Log(logger.LevelDebug, "unable to create zip entry %#v: %v", entryPath, err)
 		return err
 		return err

+ 136 - 11
httpd/httpd_test.go

@@ -7,6 +7,7 @@ import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
+	"io/fs"
 	"math"
 	"math"
 	"mime/multipart"
 	"mime/multipart"
 	"net"
 	"net"
@@ -8561,7 +8562,7 @@ func TestStartQuotaScanMock(t *testing.T) {
 	waitForUsersQuotaScan(t, token)
 	waitForUsersQuotaScan(t, token)
 
 
 	_, err = os.Stat(user.HomeDir)
 	_, err = os.Stat(user.HomeDir)
-	if err != nil && os.IsNotExist(err) {
+	if err != nil && errors.Is(err, fs.ErrNotExist) {
 		err = os.MkdirAll(user.HomeDir, os.ModePerm)
 		err = os.MkdirAll(user.HomeDir, os.ModePerm)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 	}
 	}
@@ -8725,7 +8726,7 @@ func TestStartFolderQuotaScanMock(t *testing.T) {
 	assert.True(t, common.QuotaScans.RemoveVFolderQuotaScan(folderName))
 	assert.True(t, common.QuotaScans.RemoveVFolderQuotaScan(folderName))
 	// and now a real quota scan
 	// and now a real quota scan
 	_, err = os.Stat(mappedPath)
 	_, err = os.Stat(mappedPath)
-	if err != nil && os.IsNotExist(err) {
+	if err != nil && errors.Is(err, fs.ErrNotExist) {
 		err = os.MkdirAll(mappedPath, os.ModePerm)
 		err = os.MkdirAll(mappedPath, os.ModePerm)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 	}
 	}
@@ -10137,6 +10138,10 @@ func TestShareUsage(t *testing.T) {
 	checkResponseCode(t, http.StatusForbidden, rr)
 	checkResponseCode(t, http.StatusForbidden, rr)
 	assert.Contains(t, rr.Body.String(), "permission denied")
 	assert.Contains(t, rr.Body.String(), "permission denied")
 
 
+	user.Permissions["/"] = []string{dataprovider.PermAny}
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+
 	body = new(bytes.Buffer)
 	body = new(bytes.Buffer)
 	writer = multipart.NewWriter(body)
 	writer = multipart.NewWriter(body)
 	part, err := writer.CreateFormFile("filename", "file1.txt")
 	part, err := writer.CreateFormFile("filename", "file1.txt")
@@ -10155,7 +10160,37 @@ func TestShareUsage(t *testing.T) {
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	assert.Contains(t, rr.Body.String(), "No files uploaded!")
 	assert.Contains(t, rr.Body.String(), "No files uploaded!")
 
 
-	share.Scope = dataprovider.ShareScopeRead
+	user.Filters.WebClient = []string{sdk.WebClientSharesDisabled}
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodPost, sharesPath+"/"+objectID, reader)
+	assert.NoError(t, err)
+	req.Header.Add("Content-Type", writer.FormDataContentType())
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	user.Filters.WebClient = []string{sdk.WebClientShareNoPasswordDisabled}
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	share.Password = ""
+	err = dataprovider.UpdateShare(&share, user.Username, "")
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodPost, sharesPath+"/"+objectID, reader)
+	assert.NoError(t, err)
+	req.Header.Add("Content-Type", writer.FormDataContentType())
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "sharing without a password was disabled")
+
+	user.Filters.WebClient = []string{sdk.WebClientInfoChangeDisabled}
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+
+	share.Scope = dataprovider.ShareScopeReadWrite
 	share.Paths = []string{"/missing"}
 	share.Paths = []string{"/missing"}
 	err = dataprovider.UpdateShare(&share, user.Username, "")
 	err = dataprovider.UpdateShare(&share, user.Username, "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -10347,12 +10382,6 @@ func TestShareUploadSingle(t *testing.T) {
 	if assert.NoError(t, err) {
 	if assert.NoError(t, err) {
 		assert.InDelta(t, util.GetTimeAsMsSinceEpoch(time.Now()), util.GetTimeAsMsSinceEpoch(info.ModTime()), float64(3000))
 		assert.InDelta(t, util.GetTimeAsMsSinceEpoch(time.Now()), util.GetTimeAsMsSinceEpoch(info.ModTime()), float64(3000))
 	}
 	}
-	// we don't allow to create the file in subdirectories
-	req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID, "%2Fdir%2Ffile1.txt"), bytes.NewBuffer(content))
-	assert.NoError(t, err)
-	req.SetBasicAuth(defaultUsername, defaultPassword)
-	rr = executeRequest(req)
-	checkResponseCode(t, http.StatusForbidden, rr)
 
 
 	req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID, "dir", "file.dat"), bytes.NewBuffer(content))
 	req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID, "dir", "file.dat"), bytes.NewBuffer(content))
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -10391,6 +10420,76 @@ func TestShareUploadSingle(t *testing.T) {
 	checkResponseCode(t, http.StatusNotFound, rr)
 	checkResponseCode(t, http.StatusNotFound, rr)
 }
 }
 
 
+func TestShareReadWrite(t *testing.T) {
+	u := getTestUser()
+	u.Filters.StartDirectory = path.Join("/start", "dir")
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	testFileName := "test.txt"
+
+	share := dataprovider.Share{
+		Name:      "test share rw",
+		Scope:     dataprovider.ShareScopeReadWrite,
+		Paths:     []string{user.Filters.StartDirectory},
+		Password:  defaultPassword,
+		MaxTokens: 0,
+	}
+	asJSON, err := json.Marshal(share)
+	assert.NoError(t, err)
+	req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+	objectID := rr.Header().Get("X-Object-ID")
+	assert.NotEmpty(t, objectID)
+
+	content := []byte("shared rw content")
+	req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID, testFileName), bytes.NewBuffer(content))
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+	assert.FileExists(t, filepath.Join(user.GetHomeDir(), user.Filters.StartDirectory, testFileName))
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path="+testFileName), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	contentDisposition := rr.Header().Get("Content-Disposition")
+	assert.NotEmpty(t, contentDisposition)
+
+	req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID)+"/"+url.PathEscape("../"+testFileName),
+		bytes.NewBuffer(content))
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "Uploading outside the share is not allowed")
+
+	req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID)+"/"+url.PathEscape("/../../"+testFileName),
+		bytes.NewBuffer(content))
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "Uploading outside the share is not allowed")
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestShareUncompressed(t *testing.T) {
 func TestShareUncompressed(t *testing.T) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -10799,7 +10898,7 @@ func TestBrowseShares(t *testing.T) {
 	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs"), nil)
 	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs"), nil)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
-	checkResponseCode(t, http.StatusInternalServerError, rr)
+	checkResponseCode(t, http.StatusBadRequest, rr)
 	assert.Contains(t, rr.Body.String(), "unable to check the share directory")
 	assert.Contains(t, rr.Body.String(), "unable to check the share directory")
 	// share multiple paths
 	// share multiple paths
 	share = dataprovider.Share{
 	share = dataprovider.Share{
@@ -10868,6 +10967,32 @@ func TestBrowseShares(t *testing.T) {
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 	checkResponseCode(t, http.StatusForbidden, rr)
 	assert.Contains(t, rr.Body.String(), "two-factor authentication requirements not met")
 	assert.Contains(t, rr.Body.String(), "two-factor authentication requirements not met")
+	user.Filters.TwoFactorAuthProtocols = []string{common.ProtocolSSH}
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	// share read/write
+	share.Scope = dataprovider.ShareScopeReadWrite
+	asJSON, err = json.Marshal(share)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusCreated, rr)
+	objectID = rr.Header().Get("X-Object-ID")
+	assert.NotEmpty(t, objectID)
+	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	// on upload we should be redirected
+	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.StatusFound, rr)
+	location := rr.Header().Get("Location")
+	assert.Equal(t, path.Join(webClientPubSharesPath, objectID, "browse"), location)
 
 
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -18853,7 +18978,7 @@ func checkResponseCode(t *testing.T, expected int, rr *httptest.ResponseRecorder
 
 
 func createTestFile(path string, size int64) error {
 func createTestFile(path string, size int64) error {
 	baseDir := filepath.Dir(path)
 	baseDir := filepath.Dir(path)
-	if _, err := os.Stat(baseDir); os.IsNotExist(err) {
+	if _, err := os.Stat(baseDir); errors.Is(err, fs.ErrNotExist) {
 		err = os.MkdirAll(baseDir, os.ModePerm)
 		err = os.MkdirAll(baseDir, os.ModePerm)
 		if err != nil {
 		if err != nil {
 			return err
 			return err

+ 7 - 1
httpd/internal_test.go

@@ -2291,7 +2291,13 @@ func TestMetadataAPI(t *testing.T) {
 
 
 func TestBrowsableSharePaths(t *testing.T) {
 func TestBrowsableSharePaths(t *testing.T) {
 	share := dataprovider.Share{
 	share := dataprovider.Share{
-		Paths: []string{"/"},
+		Paths:    []string{"/"},
+		Username: defaultAdminUsername,
+	}
+	_, err := getUserForShare(share)
+	if assert.Error(t, err) {
+		_, ok := err.(*util.RecordNotFoundError)
+		assert.True(t, ok)
 	}
 	}
 	req, err := http.NewRequest(http.MethodGet, "/share", nil)
 	req, err := http.NewRequest(http.MethodGet, "/share", nil)
 	require.NoError(t, err)
 	require.NoError(t, err)

+ 20 - 9
httpd/webclient.go

@@ -143,12 +143,14 @@ type filesPage struct {
 
 
 type shareFilesPage struct {
 type shareFilesPage struct {
 	baseClientPage
 	baseClientPage
-	CurrentDir  string
-	DirsURL     string
-	FilesURL    string
-	DownloadURL string
-	Error       string
-	Paths       []dirMapping
+	CurrentDir    string
+	DirsURL       string
+	FilesURL      string
+	DownloadURL   string
+	UploadBaseURL string
+	Error         string
+	Paths         []dirMapping
+	Scope         dataprovider.ShareScope
 }
 }
 
 
 type shareUploadPage struct {
 type shareUploadPage struct {
@@ -512,8 +514,10 @@ func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Reque
 		DirsURL:        path.Join(webClientPubSharesPath, share.ShareID, "dirs"),
 		DirsURL:        path.Join(webClientPubSharesPath, share.ShareID, "dirs"),
 		FilesURL:       currentURL,
 		FilesURL:       currentURL,
 		DownloadURL:    path.Join(webClientPubSharesPath, share.ShareID),
 		DownloadURL:    path.Join(webClientPubSharesPath, share.ShareID),
+		UploadBaseURL:  path.Join(webClientPubSharesPath, share.ShareID, url.PathEscape(dirName)),
 		Error:          error,
 		Error:          error,
 		Paths:          getDirMapping(dirName, currentURL),
 		Paths:          getDirMapping(dirName, currentURL),
+		Scope:          share.Scope,
 	}
 	}
 	renderClientTemplate(w, templateShareFiles, data)
 	renderClientTemplate(w, templateShareFiles, data)
 }
 }
@@ -625,7 +629,8 @@ func (s *httpdServer) handleWebClientDownloadZip(w http.ResponseWriter, r *http.
 
 
 func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.Request) {
 func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	share, connection, err := s.checkPublicShare(w, r, dataprovider.ShareScopeRead, true)
+	validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite}
+	share, connection, err := s.checkPublicShare(w, r, validScopes, true)
 	if err != nil {
 	if err != nil {
 		return
 		return
 	}
 	}
@@ -674,16 +679,22 @@ func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.R
 
 
 func (s *httpdServer) handleClientUploadToShare(w http.ResponseWriter, r *http.Request) {
 func (s *httpdServer) handleClientUploadToShare(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	share, _, err := s.checkPublicShare(w, r, dataprovider.ShareScopeWrite, true)
+	validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeWrite, dataprovider.ShareScopeReadWrite}
+	share, _, err := s.checkPublicShare(w, r, validScopes, true)
 	if err != nil {
 	if err != nil {
 		return
 		return
 	}
 	}
+	if share.Scope == dataprovider.ShareScopeReadWrite {
+		http.Redirect(w, r, path.Join(webClientPubSharesPath, share.ShareID, "browse"), http.StatusFound)
+		return
+	}
 	s.renderUploadToSharePage(w, r, share)
 	s.renderUploadToSharePage(w, r, share)
 }
 }
 
 
 func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request) {
 func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	share, connection, err := s.checkPublicShare(w, r, dataprovider.ShareScopeRead, true)
+	validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite}
+	share, connection, err := s.checkPublicShare(w, r, validScopes, true)
 	if err != nil {
 	if err != nil {
 		return
 		return
 	}
 	}

+ 2 - 1
logger/logger.go

@@ -11,6 +11,7 @@ package logger
 import (
 import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
+	"io/fs"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
 	"time"
 	"time"
@@ -57,7 +58,7 @@ func InitLogger(logFilePath string, logMaxSize int, logMaxBackups int, logMaxAge
 	SetLogTime(logUTCTime)
 	SetLogTime(logUTCTime)
 	if isLogFilePathValid(logFilePath) {
 	if isLogFilePathValid(logFilePath) {
 		logDir := filepath.Dir(logFilePath)
 		logDir := filepath.Dir(logFilePath)
-		if _, err := os.Stat(logDir); os.IsNotExist(err) {
+		if _, err := os.Stat(logDir); errors.Is(err, fs.ErrNotExist) {
 			err = os.MkdirAll(logDir, os.ModePerm)
 			err = os.MkdirAll(logDir, os.ModePerm)
 			if err != nil {
 			if err != nil {
 				fmt.Printf("unable to create log dir %#v: %v", logDir, err)
 				fmt.Printf("unable to create log dir %#v: %v", logDir, err)

+ 3 - 2
sftpd/server.go

@@ -7,6 +7,7 @@ import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
+	"io/fs"
 	"net"
 	"net"
 	"os"
 	"os"
 	"path"
 	"path"
@@ -755,7 +756,7 @@ func (c *Configuration) generateDefaultHostKeys(configDir string) error {
 	defaultHostKeys := []string{defaultPrivateRSAKeyName, defaultPrivateECDSAKeyName, defaultPrivateEd25519KeyName}
 	defaultHostKeys := []string{defaultPrivateRSAKeyName, defaultPrivateECDSAKeyName, defaultPrivateEd25519KeyName}
 	for _, k := range defaultHostKeys {
 	for _, k := range defaultHostKeys {
 		autoFile := filepath.Join(configDir, k)
 		autoFile := filepath.Join(configDir, k)
-		if _, err = os.Stat(autoFile); os.IsNotExist(err) {
+		if _, err = os.Stat(autoFile); errors.Is(err, fs.ErrNotExist) {
 			logger.Info(logSender, "", "No host keys configured and %#v does not exist; try to create a new host key", autoFile)
 			logger.Info(logSender, "", "No host keys configured and %#v does not exist; try to create a new host key", autoFile)
 			logger.InfoToConsole("No host keys configured and %#v does not exist; try to create a new host key", autoFile)
 			logger.InfoToConsole("No host keys configured and %#v does not exist; try to create a new host key", autoFile)
 			if k == defaultPrivateRSAKeyName {
 			if k == defaultPrivateRSAKeyName {
@@ -780,7 +781,7 @@ func (c *Configuration) generateDefaultHostKeys(configDir string) error {
 func (c *Configuration) checkHostKeyAutoGeneration(configDir string) error {
 func (c *Configuration) checkHostKeyAutoGeneration(configDir string) error {
 	for _, k := range c.HostKeys {
 	for _, k := range c.HostKeys {
 		if filepath.IsAbs(k) {
 		if filepath.IsAbs(k) {
-			if _, err := os.Stat(k); os.IsNotExist(err) {
+			if _, err := os.Stat(k); errors.Is(err, fs.ErrNotExist) {
 				keyName := filepath.Base(k)
 				keyName := filepath.Base(k)
 				switch keyName {
 				switch keyName {
 				case defaultPrivateRSAKeyName:
 				case defaultPrivateRSAKeyName:

+ 31 - 29
sftpd/sftpd_test.go

@@ -10,9 +10,11 @@ import (
 	"encoding/base64"
 	"encoding/base64"
 	"encoding/binary"
 	"encoding/binary"
 	"encoding/json"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"fmt"
 	"hash"
 	"hash"
 	"io"
 	"io"
+	"io/fs"
 	"math"
 	"math"
 	"net"
 	"net"
 	"net/http"
 	"net/http"
@@ -1463,11 +1465,11 @@ func TestStat(t *testing.T) {
 			assert.NoError(t, err)
 			assert.NoError(t, err)
 			_, err = client.Stat(testFileName)
 			_, err = client.Stat(testFileName)
 			assert.NoError(t, err)
 			assert.NoError(t, err)
-			// stat a missing path we should get an os.IsNotExist error
+			// stat a missing path we should get an fs.ErrNotExist error
 			_, err = client.Stat("missing path")
 			_, err = client.Stat("missing path")
-			assert.True(t, os.IsNotExist(err))
+			assert.True(t, errors.Is(err, fs.ErrNotExist))
 			_, err = client.Lstat("missing path")
 			_, err = client.Lstat("missing path")
-			assert.True(t, os.IsNotExist(err))
+			assert.True(t, errors.Is(err, fs.ErrNotExist))
 			// mode 0666 and 0444 works on Windows too
 			// mode 0666 and 0444 works on Windows too
 			newPerm := os.FileMode(0666)
 			newPerm := os.FileMode(0666)
 			err = client.Chmod(testFileName, newPerm)
 			err = client.Chmod(testFileName, newPerm)
@@ -6924,7 +6926,7 @@ func TestOpenError(t *testing.T) {
 		err = os.Chmod(filepath.Join(user.GetHomeDir(), testDir), 0000)
 		err = os.Chmod(filepath.Join(user.GetHomeDir(), testDir), 0000)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = client.Rename(testFileName, path.Join(testDir, testFileName))
 		err = client.Rename(testFileName, path.Join(testDir, testFileName))
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = os.Chmod(filepath.Join(user.GetHomeDir(), testDir), os.ModePerm)
 		err = os.Chmod(filepath.Join(user.GetHomeDir(), testDir), os.ModePerm)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = os.Remove(localDownloadPath)
 		err = os.Remove(localDownloadPath)
@@ -7253,7 +7255,7 @@ func TestPermRename(t *testing.T) {
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = client.Rename(testFileName, testFileName+".rename")
 		err = client.Rename(testFileName, testFileName+".rename")
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		_, err = client.Stat(testFileName)
 		_, err = client.Stat(testFileName)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = os.Remove(testFilePath)
 		err = os.Remove(testFilePath)
@@ -7289,7 +7291,7 @@ func TestPermRenameOverwrite(t *testing.T) {
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = client.Rename(testFileName, testFileName+".rename")
 		err = client.Rename(testFileName, testFileName+".rename")
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Remove(testFileName)
 		err = client.Remove(testFileName)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = os.Remove(testFilePath)
 		err = os.Remove(testFilePath)
@@ -7474,13 +7476,13 @@ func TestSubDirsUploads(t *testing.T) {
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = sftpUploadFile(testFilePath, testFileNameSub, testFileSize, client)
 		err = sftpUploadFile(testFilePath, testFileNameSub, testFileSize, client)
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Symlink(testFileName, testFileNameSub+".link")
 		err = client.Symlink(testFileName, testFileNameSub+".link")
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Symlink(testFileName, testFileName+".link")
 		err = client.Symlink(testFileName, testFileName+".link")
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = client.Rename(testFileName, testFileNameSub+".rename")
 		err = client.Rename(testFileName, testFileNameSub+".rename")
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Rename(testFileName, testFileName+".rename")
 		err = client.Rename(testFileName, testFileName+".rename")
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
@@ -7500,7 +7502,7 @@ func TestSubDirsUploads(t *testing.T) {
 		err = client.Remove(testDir)
 		err = client.Remove(testDir)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = client.Remove(path.Join("/subdir", "file.dat"))
 		err = client.Remove(path.Join("/subdir", "file.dat"))
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Remove(testFileName + ".rename")
 		err = client.Remove(testFileName + ".rename")
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = os.Remove(testFilePath)
 		err = os.Remove(testFilePath)
@@ -7532,7 +7534,7 @@ func TestSubDirsOverwrite(t *testing.T) {
 		err = createTestFile(testFileSFTPPath, 16384)
 		err = createTestFile(testFileSFTPPath, 16384)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = sftpUploadFile(testFilePath, testFileName+".new", testFileSize, client)
 		err = sftpUploadFile(testFilePath, testFileName+".new", testFileSize, client)
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = os.Remove(testFilePath)
 		err = os.Remove(testFilePath)
@@ -7566,17 +7568,17 @@ func TestSubDirsDownloads(t *testing.T) {
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
 		localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
 		err = sftpDownloadFile(testFileName, localDownloadPath, testFileSize, client)
 		err = sftpDownloadFile(testFileName, localDownloadPath, testFileSize, client)
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Chtimes(testFileName, time.Now(), time.Now())
 		err = client.Chtimes(testFileName, time.Now(), time.Now())
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Rename(testFileName, testFileName+".rename")
 		err = client.Rename(testFileName, testFileName+".rename")
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Symlink(testFileName, testFileName+".link")
 		err = client.Symlink(testFileName, testFileName+".link")
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Remove(testFileName)
 		err = client.Remove(testFileName)
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = os.Remove(localDownloadPath)
 		err = os.Remove(localDownloadPath)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = os.Remove(testFilePath)
 		err = os.Remove(testFilePath)
@@ -7611,9 +7613,9 @@ func TestPermsSubDirsSetstat(t *testing.T) {
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = client.Chtimes("/subdir/", time.Now(), time.Now())
 		err = client.Chtimes("/subdir/", time.Now(), time.Now())
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Chtimes("subdir/", time.Now(), time.Now())
 		err = client.Chtimes("subdir/", time.Now(), time.Now())
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Chtimes(testFileName, time.Now(), time.Now())
 		err = client.Chtimes(testFileName, time.Now(), time.Now())
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = os.Remove(testFilePath)
 		err = os.Remove(testFilePath)
@@ -7674,19 +7676,19 @@ func TestPermsSubDirsCommands(t *testing.T) {
 		_, err = client.ReadDir("/")
 		_, err = client.ReadDir("/")
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		_, err = client.ReadDir("/subdir")
 		_, err = client.ReadDir("/subdir")
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.RemoveDirectory("/subdir/dir")
 		err = client.RemoveDirectory("/subdir/dir")
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Mkdir("/subdir/otherdir/dir")
 		err = client.Mkdir("/subdir/otherdir/dir")
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Mkdir("/otherdir")
 		err = client.Mkdir("/otherdir")
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = client.Mkdir("/subdir/otherdir")
 		err = client.Mkdir("/subdir/otherdir")
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = client.Rename("/otherdir", "/subdir/otherdir/adir")
 		err = client.Rename("/otherdir", "/subdir/otherdir/adir")
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Symlink("/otherdir", "/subdir/otherdir")
 		err = client.Symlink("/otherdir", "/subdir/otherdir")
-		assert.True(t, os.IsPermission(err))
+		assert.True(t, errors.Is(err, fs.ErrPermission))
 		err = client.Symlink("/otherdir", "/otherdir_link")
 		err = client.Symlink("/otherdir", "/otherdir_link")
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		err = client.Rename("/otherdir", "/otherdir1")
 		err = client.Rename("/otherdir", "/otherdir1")
@@ -7718,11 +7720,11 @@ func TestRootDirCommands(t *testing.T) {
 			defer conn.Close()
 			defer conn.Close()
 			defer client.Close()
 			defer client.Close()
 			err = client.Rename("/", "rootdir")
 			err = client.Rename("/", "rootdir")
-			assert.True(t, os.IsPermission(err))
+			assert.True(t, errors.Is(err, fs.ErrPermission))
 			err = client.Symlink("/", "rootdir")
 			err = client.Symlink("/", "rootdir")
-			assert.True(t, os.IsPermission(err))
+			assert.True(t, errors.Is(err, fs.ErrPermission))
 			err = client.RemoveDirectory("/")
 			err = client.RemoveDirectory("/")
-			assert.True(t, os.IsPermission(err))
+			assert.True(t, errors.Is(err, fs.ErrPermission))
 		}
 		}
 		if user.Username == defaultUsername {
 		if user.Username == defaultUsername {
 			err = os.RemoveAll(user.GetHomeDir())
 			err = os.RemoveAll(user.GetHomeDir())
@@ -8200,7 +8202,7 @@ func TestStatVFS(t *testing.T) {
 
 
 		_, err = client.StatVFS("missing-path")
 		_, err = client.StatVFS("missing-path")
 		assert.Error(t, err)
 		assert.Error(t, err)
-		assert.True(t, os.IsNotExist(err))
+		assert.True(t, errors.Is(err, fs.ErrNotExist))
 	}
 	}
 	user.QuotaFiles = 100
 	user.QuotaFiles = 100
 	user.Filters.DisableFsChecks = true
 	user.Filters.DisableFsChecks = true
@@ -10524,7 +10526,7 @@ func getCustomAuthSftpClient(user dataprovider.User, authMethods []ssh.AuthMetho
 
 
 func createTestFile(path string, size int64) error {
 func createTestFile(path string, size int64) error {
 	baseDir := filepath.Dir(path)
 	baseDir := filepath.Dir(path)
-	if _, err := os.Stat(baseDir); os.IsNotExist(err) {
+	if _, err := os.Stat(baseDir); errors.Is(err, fs.ErrNotExist) {
 		err = os.MkdirAll(baseDir, os.ModePerm)
 		err = os.MkdirAll(baseDir, os.ModePerm)
 		if err != nil {
 		if err != nil {
 			return err
 			return err

+ 6 - 1
templates/webadmin/mfa.html

@@ -2,6 +2,10 @@
 
 
 {{define "title"}}{{.Title}}{{end}}
 {{define "title"}}{{.Title}}{{end}}
 
 
+{{define "extra_css"}}
+<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
+{{end}}
+
 {{define "page_body"}}
 {{define "page_body"}}
 
 
 <div class="card shadow mb-4">
 <div class="card shadow mb-4">
@@ -26,7 +30,7 @@
         <div class="form-group row">
         <div class="form-group row">
             <label for="idConfig" class="col-sm-2 col-form-label">Configuration</label>
             <label for="idConfig" class="col-sm-2 col-form-label">Configuration</label>
             <div class="col-sm-10">
             <div class="col-sm-10">
-                <select class="form-control" id="idConfig" name="config_name">
+                <select class="form-control selectpicker" id="idConfig" name="config_name">
                     <option value="">None</option>
                     <option value="">None</option>
                     {{range .TOTPConfigs}}
                     {{range .TOTPConfigs}}
                     <option value="{{.}}" {{if eq . $.TOTPConfig.ConfigName}}selected{{end}}>{{.}}</option>
                     <option value="{{.}}" {{if eq . $.TOTPConfig.ConfigName}}selected{{end}}>{{.}}</option>
@@ -127,6 +131,7 @@
 {{end}}
 {{end}}
 
 
 {{define "extra_js"}}
 {{define "extra_js"}}
+<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
 <script type="text/javascript">
 <script type="text/javascript">
 
 
     function totpGenerate() {
     function totpGenerate() {

+ 7 - 2
templates/webclient/mfa.html

@@ -2,6 +2,10 @@
 
 
 {{define "title"}}{{.Title}}{{end}}
 {{define "title"}}{{.Title}}{{end}}
 
 
+{{define "extra_css"}}
+<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
+{{end}}
+
 {{define "page_body"}}
 {{define "page_body"}}
 
 
 <div class="card shadow mb-4">
 <div class="card shadow mb-4">
@@ -33,7 +37,7 @@
         <div class="form-group row totpProtocols">
         <div class="form-group row totpProtocols">
             <label for="idProtocols" class="col-sm-3 col-form-label">Require two-factor auth for</label>
             <label for="idProtocols" class="col-sm-3 col-form-label">Require two-factor auth for</label>
             <div class="col-sm-9">
             <div class="col-sm-9">
-                <select class="form-control" id="idProtocols" name="multi_factor_protocols" multiple>
+                <select class="form-control selectpicker" id="idProtocols" name="multi_factor_protocols" multiple>
                     {{range $protocol := .Protocols}}
                     {{range $protocol := .Protocols}}
                     <option value="{{$protocol}}" {{range $p :=$.TOTPConfig.Protocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
                     <option value="{{$protocol}}" {{range $p :=$.TOTPConfig.Protocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
                     </option>
                     </option>
@@ -51,7 +55,7 @@
         <div class="form-group row">
         <div class="form-group row">
             <label for="idConfig" class="col-sm-3 col-form-label">Configuration</label>
             <label for="idConfig" class="col-sm-3 col-form-label">Configuration</label>
             <div class="col-sm-9">
             <div class="col-sm-9">
-                <select class="form-control" id="idConfig" name="config_name">
+                <select class="form-control selectpicker" id="idConfig" name="config_name">
                     <option value="">None</option>
                     <option value="">None</option>
                     {{range .TOTPConfigs}}
                     {{range .TOTPConfigs}}
                     <option value="{{.}}" {{if eq . $.TOTPConfig.ConfigName}}selected{{end}}>{{.}}</option>
                     <option value="{{.}}" {{if eq . $.TOTPConfig.ConfigName}}selected{{end}}>{{.}}</option>
@@ -152,6 +156,7 @@
 {{end}}
 {{end}}
 
 
 {{define "extra_js"}}
 {{define "extra_js"}}
+<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
 <script type="text/javascript">
 <script type="text/javascript">
 
 
     function totpGenerate() {
     function totpGenerate() {

+ 5 - 2
templates/webclient/share.html

@@ -4,6 +4,7 @@
 
 
 {{define "extra_css"}}
 {{define "extra_css"}}
 <link href="{{.StaticURL}}/vendor/tempusdominus/css/tempusdominus-bootstrap-4.min.css" rel="stylesheet">
 <link href="{{.StaticURL}}/vendor/tempusdominus/css/tempusdominus-bootstrap-4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
 {{end}}
 {{end}}
 
 
 {{define "page_body"}}
 {{define "page_body"}}
@@ -29,12 +30,13 @@
             <div class="form-group row">
             <div class="form-group row">
                 <label for="idScope" class="col-sm-2 col-form-label">Scope</label>
                 <label for="idScope" class="col-sm-2 col-form-label">Scope</label>
                 <div class="col-sm-10">
                 <div class="col-sm-10">
-                    <select class="form-control" id="idScope" name="scope" aria-describedby="scopeHelpBlock">
+                    <select class="form-control selectpicker" id="idScope" name="scope" aria-describedby="scopeHelpBlock">
                         <option value="1" {{if eq .Share.Scope 1 }}selected{{end}}>Read</option>
                         <option value="1" {{if eq .Share.Scope 1 }}selected{{end}}>Read</option>
                         <option value="2" {{if eq .Share.Scope 2 }}selected{{end}}>Write</option>
                         <option value="2" {{if eq .Share.Scope 2 }}selected{{end}}>Write</option>
+                        <option value="3" {{if eq .Share.Scope 3 }}selected{{end}}>Read/Write</option>
                     </select>
                     </select>
                     <small id="scopeHelpBlock" class="form-text text-muted">
                     <small id="scopeHelpBlock" class="form-text text-muted">
-                        For scope "Write" you have to define one path and it must be a directory
+                        For scope "Write" and "Read&Write" you have to define one path and it must be a directory
                     </small>
                     </small>
                 </div>
                 </div>
             </div>
             </div>
@@ -144,6 +146,7 @@
 {{define "extra_js"}}
 {{define "extra_js"}}
 <script src="{{.StaticURL}}/vendor/moment/js/moment.min.js"></script>
 <script src="{{.StaticURL}}/vendor/moment/js/moment.min.js"></script>
 <script src="{{.StaticURL}}/vendor/tempusdominus/js/tempusdominus-bootstrap-4.min.js"></script>
 <script src="{{.StaticURL}}/vendor/tempusdominus/js/tempusdominus-bootstrap-4.min.js"></script>
+<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
 <script type="text/javascript">
 <script type="text/javascript">
     $(document).ready(function () {
     $(document).ready(function () {
 
 

+ 133 - 0
templates/webclient/sharefiles.html

@@ -38,6 +38,37 @@
     </div>
     </div>
 </div>
 </div>
 {{end}}
 {{end}}
+{{define "dialog"}}
+<div class="modal fade" id="uploadFilesModal" tabindex="-1" role="dialog" aria-labelledby="uploadFilesModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="uploadFilesModalLabel">
+                    Upload one or more files
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <form id="upload_files_form" action="{{.FilesURL}}?path={{.CurrentDir}}" 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>
+                <div class="modal-footer">
+                    <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
+                    <button type="submit" class="btn btn-primary">Submit</button>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
+<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"}}
 {{define "extra_js"}}
 <script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
 <script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
@@ -171,6 +202,94 @@
     }
     }
 
 
     $(document).ready(function () {
     $(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;
+
+            $('#uploadFilesModal').modal('hide');
+            $('#spinnerModal').modal('show');
+
+            function uploadFile() {
+                if (index >= files.length || has_errors){
+                    $('#spinnerModal').modal('hide');
+                    spinnerDone = true;
+                    if (!has_errors){
+                        location.reload();
+                    }
+                    return;
+                }
+
+                async function saveFile() {
+                    var errorMessage = "Error uploading files";
+                    let response;
+                    try {
+                        var f = files[index];
+                        var uploadPath = '{{.UploadBaseURL}}'+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();
+        });
+
         $.fn.dataTable.ext.buttons.refresh = {
         $.fn.dataTable.ext.buttons.refresh = {
             text: '<i class="fas fa-sync-alt"></i>',
             text: '<i class="fas fa-sync-alt"></i>',
             name: 'refresh',
             name: 'refresh',
@@ -191,6 +310,17 @@
             }
             }
         };
         };
 
 
+        $.fn.dataTable.ext.buttons.addFiles = {
+            text: '<i class="fas fa-file-upload"></i>',
+            name: 'addFiles',
+            titleAttr: "Upload files",
+            action: function (e, dt, node, config) {
+                document.getElementById("files_name").value = null;
+                $('#uploadFilesModal').modal('show');
+            },
+            enabled: true
+        };
+
         var table = $('#dataTable').DataTable({
         var table = $('#dataTable').DataTable({
             "ajax": {
             "ajax": {
                 "url": "{{.DirsURL}}?path={{.CurrentDir}}",
                 "url": "{{.DirsURL}}?path={{.CurrentDir}}",
@@ -283,6 +413,9 @@
                 table.button().add(0, 'refresh');
                 table.button().add(0, 'refresh');
                 table.button().add(0, 'pageLength');
                 table.button().add(0, 'pageLength');
                 table.button().add(0, 'download');
                 table.button().add(0, 'download');
+                {{if gt .Scope 1}}
+                table.button().add(0, 'addFiles');
+                {{end}}
                 table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
                 table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
             },
             },
             "orderFixed": [0, 'asc'],
             "orderFixed": [0, 'asc'],

+ 13 - 1
util/util.go

@@ -15,6 +15,7 @@ import (
 	"fmt"
 	"fmt"
 	"html/template"
 	"html/template"
 	"io"
 	"io"
+	"io/fs"
 	"net"
 	"net"
 	"net/http"
 	"net/http"
 	"net/url"
 	"net/url"
@@ -45,7 +46,18 @@ var (
 	trueClientIP   = http.CanonicalHeaderKey("True-Client-IP")
 	trueClientIP   = http.CanonicalHeaderKey("True-Client-IP")
 )
 )
 
 
+// Contains reports whether v is present in elems.
+func Contains[T comparable](elems []T, v T) bool {
+	for _, s := range elems {
+		if v == s {
+			return true
+		}
+	}
+	return false
+}
+
 // IsStringInSlice searches a string in a slice and returns true if the string is found
 // IsStringInSlice searches a string in a slice and returns true if the string is found
+// TODO: replace with Contains above
 func IsStringInSlice(obj string, list []string) bool {
 func IsStringInSlice(obj string, list []string) bool {
 	for i := 0; i < len(list); i++ {
 	for i := 0; i < len(list); i++ {
 		if list[i] == obj {
 		if list[i] == obj {
@@ -390,7 +402,7 @@ func CleanDirInput(dirInput string) string {
 
 
 func createDirPathIfMissing(file string, perm os.FileMode) error {
 func createDirPathIfMissing(file string, perm os.FileMode) error {
 	dirPath := filepath.Dir(file)
 	dirPath := filepath.Dir(file)
-	if _, err := os.Stat(dirPath); os.IsNotExist(err) {
+	if _, err := os.Stat(dirPath); errors.Is(err, fs.ErrNotExist) {
 		err = os.MkdirAll(dirPath, perm)
 		err = os.MkdirAll(dirPath, perm)
 		if err != nil {
 		if err != nil {
 			return err
 			return err

+ 8 - 6
vfs/osfs.go

@@ -1,8 +1,10 @@
 package vfs
 package vfs
 
 
 import (
 import (
+	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
+	"io/fs"
 	"net/http"
 	"net/http"
 	"os"
 	"os"
 	"path"
 	"path"
@@ -200,7 +202,7 @@ func (*OsFs) IsAtomicUploadSupported() bool {
 // IsNotExist returns a boolean indicating whether the error is known to
 // IsNotExist returns a boolean indicating whether the error is known to
 // report that a file or directory does not exist
 // report that a file or directory does not exist
 func (*OsFs) IsNotExist(err error) bool {
 func (*OsFs) IsNotExist(err error) bool {
-	return os.IsNotExist(err)
+	return errors.Is(err, fs.ErrNotExist)
 }
 }
 
 
 // IsPermission returns a boolean indicating whether the error is known to
 // IsPermission returns a boolean indicating whether the error is known to
@@ -209,8 +211,7 @@ func (*OsFs) IsPermission(err error) bool {
 	if _, ok := err.(*pathResolutionError); ok {
 	if _, ok := err.(*pathResolutionError); ok {
 		return true
 		return true
 	}
 	}
-
-	return os.IsPermission(err)
+	return errors.Is(err, fs.ErrPermission)
 }
 }
 
 
 // IsNotSupported returns true if the error indicate an unsupported operation
 // IsNotSupported returns true if the error indicate an unsupported operation
@@ -297,9 +298,10 @@ func (fs *OsFs) ResolvePath(virtualPath string) (string, error) {
 	if isInvalidNameError(err) {
 	if isInvalidNameError(err) {
 		err = os.ErrNotExist
 		err = os.ErrNotExist
 	}
 	}
-	if err != nil && !os.IsNotExist(err) {
+	isNotExist := fs.IsNotExist(err)
+	if err != nil && !isNotExist {
 		return "", err
 		return "", err
-	} else if os.IsNotExist(err) {
+	} else if isNotExist {
 		// The requested path doesn't exist, so at this point we need to iterate up the
 		// The requested path doesn't exist, so at this point we need to iterate up the
 		// path chain until we hit a directory that _does_ exist and can be validated.
 		// path chain until we hit a directory that _does_ exist and can be validated.
 		_, err = fs.findFirstExistingDir(r)
 		_, err = fs.findFirstExistingDir(r)
@@ -349,7 +351,7 @@ func (fs *OsFs) findNonexistentDirs(filePath string) ([]string, error) {
 	parent := filepath.Dir(cleanPath)
 	parent := filepath.Dir(cleanPath)
 	_, err := os.Stat(parent)
 	_, err := os.Stat(parent)
 
 
-	for os.IsNotExist(err) {
+	for fs.IsNotExist(err) {
 		results = append(results, parent)
 		results = append(results, parent)
 		parent = filepath.Dir(parent)
 		parent = filepath.Dir(parent)
 		_, err = os.Stat(parent)
 		_, err = os.Stat(parent)

+ 7 - 5
vfs/sftpfs.go

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
+	"io/fs"
 	"net"
 	"net"
 	"net/http"
 	"net/http"
 	"os"
 	"os"
@@ -431,7 +432,7 @@ func (fs *SFTPFs) IsAtomicUploadSupported() bool {
 // IsNotExist returns a boolean indicating whether the error is known to
 // IsNotExist returns a boolean indicating whether the error is known to
 // report that a file or directory does not exist
 // report that a file or directory does not exist
 func (*SFTPFs) IsNotExist(err error) bool {
 func (*SFTPFs) IsNotExist(err error) bool {
-	return os.IsNotExist(err)
+	return errors.Is(err, fs.ErrNotExist)
 }
 }
 
 
 // IsPermission returns a boolean indicating whether the error is known to
 // IsPermission returns a boolean indicating whether the error is known to
@@ -440,7 +441,7 @@ func (*SFTPFs) IsPermission(err error) bool {
 	if _, ok := err.(*pathResolutionError); ok {
 	if _, ok := err.(*pathResolutionError); ok {
 		return true
 		return true
 	}
 	}
-	return os.IsPermission(err)
+	return errors.Is(err, fs.ErrPermission)
 }
 }
 
 
 // IsNotSupported returns true if the error indicate an unsupported operation
 // IsNotSupported returns true if the error indicate an unsupported operation
@@ -559,12 +560,13 @@ func (fs *SFTPFs) ResolvePath(virtualPath string) (string, error) {
 		var validatedPath string
 		var validatedPath string
 		var err error
 		var err error
 		validatedPath, err = fs.getRealPath(fsPath)
 		validatedPath, err = fs.getRealPath(fsPath)
-		if err != nil && !os.IsNotExist(err) {
+		isNotExist := fs.IsNotExist(err)
+		if err != nil && !isNotExist {
 			fsLog(fs, logger.LevelError, "Invalid path resolution, original path %v resolved %#v err: %v",
 			fsLog(fs, logger.LevelError, "Invalid path resolution, original path %v resolved %#v err: %v",
 				virtualPath, fsPath, err)
 				virtualPath, fsPath, err)
 			return "", err
 			return "", err
-		} else if os.IsNotExist(err) {
-			for os.IsNotExist(err) {
+		} else if isNotExist {
+			for fs.IsNotExist(err) {
 				validatedPath = path.Dir(validatedPath)
 				validatedPath = path.Dir(validatedPath)
 				if validatedPath == "/" {
 				if validatedPath == "/" {
 					err = nil
 					err = nil

+ 8 - 8
webdavd/internal_test.go

@@ -644,13 +644,13 @@ func TestRemoveDirTree(t *testing.T) {
 	p := filepath.Join(user.HomeDir, "adir", "missing")
 	p := filepath.Join(user.HomeDir, "adir", "missing")
 	err := connection.removeDirTree(fs, p, vpath)
 	err := connection.removeDirTree(fs, p, vpath)
 	if assert.Error(t, err) {
 	if assert.Error(t, err) {
-		assert.True(t, os.IsNotExist(err))
+		assert.True(t, fs.IsNotExist(err))
 	}
 	}
 
 
 	fs = newMockOsFs(nil, false, "mockID", user.HomeDir, nil)
 	fs = newMockOsFs(nil, false, "mockID", user.HomeDir, nil)
 	err = connection.removeDirTree(fs, p, vpath)
 	err = connection.removeDirTree(fs, p, vpath)
 	if assert.Error(t, err) {
 	if assert.Error(t, err) {
-		assert.True(t, os.IsNotExist(err), "unexpected error: %v", err)
+		assert.True(t, fs.IsNotExist(err), "unexpected error: %v", err)
 	}
 	}
 
 
 	errFake := errors.New("fake err")
 	errFake := errors.New("fake err")
@@ -663,7 +663,7 @@ func TestRemoveDirTree(t *testing.T) {
 	fs = newMockOsFs(errWalkDir, true, "mockID", user.HomeDir, nil)
 	fs = newMockOsFs(errWalkDir, true, "mockID", user.HomeDir, nil)
 	err = connection.removeDirTree(fs, p, vpath)
 	err = connection.removeDirTree(fs, p, vpath)
 	if assert.Error(t, err) {
 	if assert.Error(t, err) {
-		assert.True(t, os.IsPermission(err), "unexpected error: %v", err)
+		assert.True(t, fs.IsPermission(err), "unexpected error: %v", err)
 	}
 	}
 
 
 	fs = newMockOsFs(errWalkFile, false, "mockID", user.HomeDir, nil)
 	fs = newMockOsFs(errWalkFile, false, "mockID", user.HomeDir, nil)
@@ -766,9 +766,9 @@ func TestTransferReadWriteErrors(t *testing.T) {
 		common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{})
 		common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{})
 	davFile = newWebDavFile(baseTransfer, nil, nil)
 	davFile = newWebDavFile(baseTransfer, nil, nil)
 	_, err = davFile.Read(p)
 	_, err = davFile.Read(p)
-	assert.True(t, os.IsNotExist(err))
+	assert.True(t, fs.IsNotExist(err))
 	_, err = davFile.Stat()
 	_, err = davFile.Stat()
-	assert.True(t, os.IsNotExist(err))
+	assert.True(t, fs.IsNotExist(err))
 
 
 	baseTransfer = common.NewBaseTransfer(nil, connection.BaseConnection, nil, testFilePath, testFilePath, testFile,
 	baseTransfer = common.NewBaseTransfer(nil, connection.BaseConnection, nil, testFilePath, testFilePath, testFile,
 		common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{})
 		common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{})
@@ -852,7 +852,7 @@ func TestTransferSeek(t *testing.T) {
 		common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{AllowedTotalSize: 100})
 		common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{AllowedTotalSize: 100})
 	davFile = newWebDavFile(baseTransfer, nil, nil)
 	davFile = newWebDavFile(baseTransfer, nil, nil)
 	_, err = davFile.Seek(0, io.SeekCurrent)
 	_, err = davFile.Seek(0, io.SeekCurrent)
-	assert.True(t, os.IsNotExist(err))
+	assert.True(t, fs.IsNotExist(err))
 	davFile.Connection.RemoveTransfer(davFile.BaseTransfer)
 	davFile.Connection.RemoveTransfer(davFile.BaseTransfer)
 
 
 	err = os.WriteFile(testFilePath, testFileContents, os.ModePerm)
 	err = os.WriteFile(testFilePath, testFileContents, os.ModePerm)
@@ -888,7 +888,7 @@ func TestTransferSeek(t *testing.T) {
 		common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{AllowedTotalSize: 100})
 		common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{AllowedTotalSize: 100})
 	davFile = newWebDavFile(baseTransfer, nil, nil)
 	davFile = newWebDavFile(baseTransfer, nil, nil)
 	_, err = davFile.Seek(0, io.SeekEnd)
 	_, err = davFile.Seek(0, io.SeekEnd)
-	assert.True(t, os.IsNotExist(err))
+	assert.True(t, fs.IsNotExist(err))
 	davFile.Connection.RemoveTransfer(davFile.BaseTransfer)
 	davFile.Connection.RemoveTransfer(davFile.BaseTransfer)
 
 
 	baseTransfer = common.NewBaseTransfer(nil, connection.BaseConnection, nil, testFilePath, testFilePath, testFile,
 	baseTransfer = common.NewBaseTransfer(nil, connection.BaseConnection, nil, testFilePath, testFilePath, testFile,
@@ -912,7 +912,7 @@ func TestTransferSeek(t *testing.T) {
 	davFile = newWebDavFile(baseTransfer, nil, nil)
 	davFile = newWebDavFile(baseTransfer, nil, nil)
 	davFile.Fs = newMockOsFs(nil, true, fs.ConnectionID(), user.GetHomeDir(), nil)
 	davFile.Fs = newMockOsFs(nil, true, fs.ConnectionID(), user.GetHomeDir(), nil)
 	res, err = davFile.Seek(2, io.SeekEnd)
 	res, err = davFile.Seek(2, io.SeekEnd)
-	assert.True(t, os.IsNotExist(err))
+	assert.True(t, fs.IsNotExist(err))
 	assert.Equal(t, int64(0), res)
 	assert.Equal(t, int64(0), res)
 
 
 	assert.Len(t, common.Connections.GetStats(), 0)
 	assert.Len(t, common.Connections.GetStats(), 0)

+ 3 - 1
webdavd/webdavd_test.go

@@ -6,8 +6,10 @@ import (
 	"crypto/rand"
 	"crypto/rand"
 	"crypto/tls"
 	"crypto/tls"
 	"encoding/json"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
+	"io/fs"
 	"net"
 	"net"
 	"net/http"
 	"net/http"
 	"os"
 	"os"
@@ -2916,7 +2918,7 @@ func getExitCodeScriptContent(exitCode int) []byte {
 
 
 func createTestFile(path string, size int64) error {
 func createTestFile(path string, size int64) error {
 	baseDir := filepath.Dir(path)
 	baseDir := filepath.Dir(path)
-	if _, err := os.Stat(baseDir); os.IsNotExist(err) {
+	if _, err := os.Stat(baseDir); errors.Is(err, fs.ErrNotExist) {
 		err = os.MkdirAll(baseDir, os.ModePerm)
 		err = os.MkdirAll(baseDir, os.ModePerm)
 		if err != nil {
 		if err != nil {
 			return err
 			return err