Browse Source

WebClient: do not silently overwrite files/directories

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 1 year ago
parent
commit
3121c35437

+ 0 - 1
docs/full-configuration.md

@@ -352,7 +352,6 @@ The configuration file contains the following sections:
       - `content_security_policy`, string. Allows to set the `Content-Security-Policy` header value. Default: blank.
       - `permissions_policy`, string. Allows to set the `Permissions-Policy` header value. Default: blank.
       - `cross_origin_opener_policy`, string. Allows to set the `Cross-Origin-Opener-Policy` header value. Default: blank.
-      - `expect_ct_header`, string. Allows to set the `Expect-CT` header value. Default: blank.
     - `branding`, struct. Defines the supported customizations to suit your brand. It contains the `web_admin` and `web_client` structs that define customizations for the WebAdmin and the WebClient UIs. Each customization struct contains the following fields:
       - `name`, string. Defines the UI name
       - `short_name`, string. Defines the short name to show next to the logo image and on the login page

+ 3 - 3
go.mod

@@ -48,7 +48,7 @@ require (
 	github.com/pires/go-proxyproto v0.7.0
 	github.com/pkg/sftp v1.13.6
 	github.com/pquerna/otp v1.4.0
-	github.com/prometheus/client_golang v1.17.0
+	github.com/prometheus/client_golang v1.18.0
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/rs/cors v1.10.1
 	github.com/rs/xid v1.5.0
@@ -61,13 +61,13 @@ require (
 	github.com/stretchr/testify v1.8.4
 	github.com/studio-b12/gowebdav v0.9.0
 	github.com/subosito/gotenv v1.6.0
-	github.com/unrolled/secure v1.13.0
+	github.com/unrolled/secure v1.14.0
 	github.com/wagslane/go-password-validator v0.3.0
 	github.com/wneessen/go-mail v0.4.1-0.20230815095916-0189acf1e45f
 	github.com/yl2chen/cidranger v1.0.3-0.20210928021809-d1cb2c52f37a
 	go.etcd.io/bbolt v1.3.8
 	go.uber.org/automaxprocs v1.5.3
-	gocloud.dev v0.35.0
+	gocloud.dev v0.36.0
 	golang.org/x/crypto v0.17.0
 	golang.org/x/net v0.19.0
 	golang.org/x/oauth2 v0.15.0

+ 6 - 6
go.sum

@@ -327,8 +327,8 @@ github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
 github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
 github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
 github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
-github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
-github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
+github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
+github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
 github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
@@ -397,8 +397,8 @@ github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5I
 github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
 github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4=
 github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDguyOZRUzAY=
-github.com/unrolled/secure v1.13.0 h1:sdr3Phw2+f8Px8HE5sd1EHdj1aV3yUwed/uZXChLFsk=
-github.com/unrolled/secure v1.13.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
+github.com/unrolled/secure v1.14.0 h1:u9vJTU/pR4Bny0ntLUMxdfLtmIRGvQf2sEFuA0TG9AE=
+github.com/unrolled/secure v1.14.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
 github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=
 github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
 github.com/wneessen/go-mail v0.4.1-0.20230815095916-0189acf1e45f h1:IYzF42VUzA6es43UO0q8rdB1+d7fge5ALPOVKN192jA=
@@ -429,8 +429,8 @@ go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
 go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-gocloud.dev v0.35.0 h1:x/Gtt5OJdT4j+ir1AXAIXb7bBnFawXAAaJptCUGk3HU=
-gocloud.dev v0.35.0/go.mod h1:wbyF+BhfdtLWyUtVEWRW13hFLb1vXnV2ovEhYGQe3ck=
+gocloud.dev v0.36.0 h1:q5zoXux4xkOZP473e1EZbG8Gq9f0vlg1VNH5Du/ybus=
+gocloud.dev v0.36.0/go.mod h1:bLxah6JQVKBaIxzsr5BQLYB4IYdWHkMZdzCXlo6F0gg=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20231226003508-02704c960a9b h1:kLiC65FbiHWFAOu+lxwNPujcsl8VYyTYYEZnsOO1WK4=
 golang.org/x/exp v0.0.0-20231226003508-02704c960a9b/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=

+ 1 - 1
internal/common/connection.go

@@ -1164,7 +1164,7 @@ func (c *BaseConnection) isRenamePermitted(fsSrc, fsDst vfs.Fs, fsSourcePath, fs
 	virtualTargetPath string, fi os.FileInfo,
 ) bool {
 	if !c.IsSameResource(virtualSourcePath, virtualTargetPath) {
-		c.Log(logger.LevelInfo, "rename %#q->%q is not allowed: the paths must be on the same resource",
+		c.Log(logger.LevelInfo, "rename %q->%q is not allowed: the paths must be on the same resource",
 			virtualSourcePath, virtualTargetPath)
 		return false
 	}

+ 0 - 7
internal/config/config.go

@@ -147,7 +147,6 @@ var (
 			ContentSecurityPolicy:   "",
 			PermissionsPolicy:       "",
 			CrossOriginOpenerPolicy: "",
-			ExpectCTHeader:          "",
 		},
 		Branding: httpd.Branding{},
 	}
@@ -1542,12 +1541,6 @@ func getHTTPDSecurityConfFromEnv(idx int) (httpd.SecurityConf, bool) { //nolint:
 		isSet = true
 	}
 
-	expectCTHeader, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__EXPECT_CT_HEADER", idx))
-	if ok {
-		result.ExpectCTHeader = expectCTHeader
-		isSet = true
-	}
-
 	return result, isSet
 }
 

+ 0 - 3
internal/config/config_test.go

@@ -1237,7 +1237,6 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CONTENT_SECURITY_POLICY", "script-src $NONCE")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__PERMISSIONS_POLICY", "fullscreen=(), geolocation=()")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CROSS_ORIGIN_OPENER_POLICY", "same-origin")
-	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__EXPECT_CT_HEADER", `max-age=86400, enforce, report-uri="https://foo.example/report"`)
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__EXTRA_CSS__0__PATH", "path1")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__EXTRA_CSS__1__PATH", "path2")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_ADMIN__FAVICON_PATH", "favicon.ico")
@@ -1303,7 +1302,6 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CONTENT_SECURITY_POLICY")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__PERMISSIONS_POLICY")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CROSS_ORIGIN_OPENER_POLICY")
-		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__EXPECT_CT_HEADER")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__EXTRA_CSS__0__PATH")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__EXTRA_CSS__1__PATH")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_ADMIN__FAVICON_PATH")
@@ -1414,7 +1412,6 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
 	require.Equal(t, "script-src $NONCE", bindings[2].Security.ContentSecurityPolicy)
 	require.Equal(t, "fullscreen=(), geolocation=()", bindings[2].Security.PermissionsPolicy)
 	require.Equal(t, "same-origin", bindings[2].Security.CrossOriginOpenerPolicy)
-	require.Equal(t, `max-age=86400, enforce, report-uri="https://foo.example/report"`, bindings[2].Security.ExpectCTHeader)
 	require.Equal(t, "favicon.ico", bindings[2].Branding.WebAdmin.FaviconPath)
 	require.Equal(t, "logo.png", bindings[2].Branding.WebClient.LogoPath)
 	require.Equal(t, "login_image.png", bindings[2].Branding.WebAdmin.LoginImagePath)

+ 10 - 7
internal/httpd/api_shares.go

@@ -201,7 +201,7 @@ func (s *httpdServer) readBrowsableShareContents(w http.ResponseWriter, r *http.
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 	}
-	name, err := getBrowsableSharedPath(share, r)
+	name, err := getBrowsableSharedPath(share.Paths[0], r)
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
@@ -232,7 +232,7 @@ func (s *httpdServer) downloadBrowsableSharedFile(w http.ResponseWriter, r *http
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 	}
-	name, err := getBrowsableSharedPath(share, r)
+	name, err := getBrowsableSharedPath(share.Paths[0], r)
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
@@ -552,7 +552,10 @@ func validateBrowsableShare(share dataprovider.Share, connection *Connection) er
 	basePath := share.Paths[0]
 	info, err := connection.Stat(basePath, 0)
 	if err != nil {
-		return fmt.Errorf("unable to check the share directory: %w", err)
+		return util.NewI18nError(
+			fmt.Errorf("unable to check the share directory: %w", err),
+			util.I18nErrorShareInvalidPath,
+		)
 	}
 	if !info.IsDir() {
 		return util.NewI18nError(
@@ -563,12 +566,12 @@ func validateBrowsableShare(share dataprovider.Share, connection *Connection) er
 	return nil
 }
 
-func getBrowsableSharedPath(share dataprovider.Share, r *http.Request) (string, error) {
-	name := util.CleanPath(path.Join(share.Paths[0], r.URL.Query().Get("path")))
-	if share.Paths[0] == "/" {
+func getBrowsableSharedPath(shareBasePath string, r *http.Request) (string, error) {
+	name := util.CleanPath(path.Join(shareBasePath, r.URL.Query().Get("path")))
+	if shareBasePath == "/" {
 		return name, nil
 	}
-	if name != share.Paths[0] && !strings.HasPrefix(name, share.Paths[0]+"/") {
+	if name != shareBasePath && !strings.HasPrefix(name, shareBasePath+"/") {
 		return "", util.NewI18nError(
 			util.NewValidationError(fmt.Sprintf("Invalid path %q", r.URL.Query().Get("path"))),
 			util.I18nErrorPathInvalid,

+ 2 - 2
internal/httpd/api_utils.go

@@ -112,11 +112,11 @@ func getRespStatus(err error) int {
 func getMappedStatusCode(err error) int {
 	var statusCode int
 	switch {
-	case errors.Is(err, os.ErrPermission):
+	case errors.Is(err, fs.ErrPermission):
 		statusCode = http.StatusForbidden
 	case errors.Is(err, common.ErrReadQuotaExceeded):
 		statusCode = http.StatusForbidden
-	case errors.Is(err, os.ErrNotExist):
+	case errors.Is(err, fs.ErrNotExist):
 		statusCode = http.StatusNotFound
 	case errors.Is(err, common.ErrQuotaExceeded):
 		statusCode = http.StatusRequestEntityTooLarge

+ 4 - 3
internal/httpd/httpd.go

@@ -178,6 +178,7 @@ const (
 	webClientResetPwdPathDefault          = "/web/client/reset-password"
 	webClientViewPDFPathDefault           = "/web/client/viewpdf"
 	webClientGetPDFPathDefault            = "/web/client/getpdf"
+	webClientExistPathDefault             = "/web/client/exist"
 	webStaticFilesPathDefault             = "/static"
 	webOpenAPIPathDefault                 = "/openapi"
 	// MaxRestoreSize defines the max size for the loaddata input file
@@ -278,6 +279,7 @@ var (
 	webClientResetPwdPath          string
 	webClientViewPDFPath           string
 	webClientGetPDFPath            string
+	webClientExistPath             string
 	webStaticFilesPath             string
 	webOpenAPIPath                 string
 	// max upload size for http clients, 1GB by default
@@ -341,9 +343,7 @@ type SecurityConf struct {
 	PermissionsPolicy string `json:"permissions_policy" mapstructure:"permissions_policy"`
 	// CrossOriginOpenerPolicy allows to set the `Cross-Origin-Opener-Policy` header value. Default is "".
 	CrossOriginOpenerPolicy string `json:"cross_origin_opener_policy" mapstructure:"cross_origin_opener_policy"`
-	// ExpectCTHeader allows to set the Expect-CT header value. Default is "".
-	ExpectCTHeader string `json:"expect_ct_header" mapstructure:"expect_ct_header"`
-	proxyHeaders   []string
+	proxyHeaders            []string
 }
 
 func (s *SecurityConf) updateProxyHeaders() {
@@ -1110,6 +1110,7 @@ func updateWebClientURLs(baseURL string) {
 	webClientResetPwdPath = path.Join(baseURL, webClientResetPwdPathDefault)
 	webClientViewPDFPath = path.Join(baseURL, webClientViewPDFPathDefault)
 	webClientGetPDFPath = path.Join(baseURL, webClientGetPDFPathDefault)
+	webClientExistPath = path.Join(baseURL, webClientExistPathDefault)
 }
 
 func updateWebAdminURLs(baseURL string) {

+ 211 - 0
internal/httpd/httpd_test.go

@@ -193,6 +193,7 @@ const (
 	webClientResetPwdPath          = "/web/client/reset-password"
 	webClientViewPDFPath           = "/web/client/viewpdf"
 	webClientGetPDFPath            = "/web/client/getpdf"
+	webClientExistPath             = "/web/client/exist"
 	httpBaseURL                    = "http://127.0.0.1:8081"
 	defaultRemoteAddr              = "127.0.0.1:1234"
 	sftpServerAddr                 = "127.0.0.1:8022"
@@ -13893,6 +13894,12 @@ func TestShareMaxSessions(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 	assert.Contains(t, rr.Body.String(), util.I18nError429Message)
 
+	req, err = http.NewRequest(http.MethodPost, webClientPubSharesPath+"/"+objectID+"/browse/exist", nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "invalid share scope")
+
 	req, err = http.NewRequest(http.MethodGet, sharesPath+"/"+objectID+"/files?path=afile", nil)
 	assert.NoError(t, err)
 	rr = executeRequest(req)
@@ -13958,6 +13965,27 @@ func TestShareMaxSessions(t *testing.T) {
 	checkResponseCode(t, http.StatusTooManyRequests, rr)
 	assert.Contains(t, rr.Body.String(), "too many open sessions")
 
+	share = dataprovider.Share{
+		Name:  "test share max sessions read/write",
+		Scope: dataprovider.ShareScopeReadWrite,
+		Paths: []string{"/"},
+	}
+	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.MethodPost, webClientPubSharesPath+"/"+objectID+"/browse/exist", nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusTooManyRequests, rr)
+	assert.Contains(t, rr.Body.String(), "too many open sessions")
+
 	common.Connections.Remove(connection.GetID())
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
@@ -14088,6 +14116,21 @@ func TestShareReadWrite(t *testing.T) {
 	objectID := rr.Header().Get("X-Object-ID")
 	assert.NotEmpty(t, objectID)
 
+	filesToCheck := make(map[string]any)
+	filesToCheck["files"] = []string{testFileName}
+	asJSON, err = json.Marshal(filesToCheck)
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodPost, path.Join(webClientPubSharesPath, objectID, "/browse/exist?path=%2F"), bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	var fileList []any
+	err = json.Unmarshal(rr.Body.Bytes(), &fileList)
+	assert.NoError(t, err)
+	assert.Len(t, fileList, 0)
+
 	content := []byte("shared rw content")
 	req, err = http.NewRequest(http.MethodPost, path.Join(sharesPath, objectID, testFileName), bytes.NewBuffer(content))
 	assert.NoError(t, err)
@@ -14096,6 +14139,16 @@ func TestShareReadWrite(t *testing.T) {
 	checkResponseCode(t, http.StatusCreated, rr)
 	assert.FileExists(t, filepath.Join(user.GetHomeDir(), user.Filters.StartDirectory, testFileName))
 
+	req, err = http.NewRequest(http.MethodPost, path.Join(webClientPubSharesPath, objectID, "/browse/exist?path=%2F"), bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	fileList = nil
+	err = json.Unmarshal(rr.Body.Bytes(), &fileList)
+	assert.NoError(t, err)
+	assert.Len(t, fileList, 1)
+
 	req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, objectID, "browse?path=%2F"), nil)
 	assert.NoError(t, err)
 	req.SetBasicAuth(defaultUsername, defaultPassword)
@@ -14721,6 +14774,50 @@ func TestBrowseShares(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	assert.Contains(t, rr.Body.String(), util.I18nErrorShareBrowsePaths)
+
+	share = dataprovider.Share{
+		Name:      "test share rw",
+		Scope:     dataprovider.ShareScopeReadWrite,
+		Paths:     []string{"/missingdir"},
+		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)
+	req, err = http.NewRequest(http.MethodPost, path.Join(webClientPubSharesPath, objectID, "/browse/exist?path=%2F"), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "unable to check the share directory")
+
+	share = dataprovider.Share{
+		Name:      "test share rw",
+		Scope:     dataprovider.ShareScopeReadWrite,
+		Paths:     []string{shareDir},
+		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)
+	req, err = http.NewRequest(http.MethodPost, path.Join(webClientPubSharesPath, objectID, "/browse/exist?path=%2F.."), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Invalid path")
 	// share the root path
 	share = dataprovider.Share{
 		Name:      "test share root",
@@ -15336,6 +15433,120 @@ func TestUserAPIKey(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestWebClientExistenceCheck(t *testing.T) {
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+
+	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+	assert.NoError(t, err)
+
+	req, err := http.NewRequest(http.MethodPost, webClientExistPath, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr) // no CSRF header
+
+	req, err = http.NewRequest(http.MethodPost, webClientExistPath, bytes.NewBuffer([]byte(`[]`)))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	setCSRFHeaderForReq(req, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	filesToCheck := make(map[string]any)
+	filesToCheck["files"] = nil
+	asJSON, err := json.Marshal(filesToCheck)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, webClientExistPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	setCSRFHeaderForReq(req, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "files to be checked are mandatory")
+
+	testFileName := "file.dat"
+	testDirName := "adirname"
+	filesToCheck["files"] = []string{testFileName}
+	asJSON, err = json.Marshal(filesToCheck)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, webClientExistPath+"?path=%2Fmissingdir", bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	setCSRFHeaderForReq(req, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodPost, webClientExistPath+"?path=%2F", bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	setCSRFHeaderForReq(req, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	var fileList []any
+	err = json.Unmarshal(rr.Body.Bytes(), &fileList)
+	assert.NoError(t, err)
+	assert.Len(t, fileList, 0)
+
+	err = createTestFile(filepath.Join(user.GetHomeDir(), testFileName), 100)
+	assert.NoError(t, err)
+	err = os.Mkdir(filepath.Join(user.GetHomeDir(), testDirName), 0755)
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodPost, webClientExistPath+"?path=%2F", bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	setCSRFHeaderForReq(req, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	fileList = nil
+	err = json.Unmarshal(rr.Body.Bytes(), &fileList)
+	assert.NoError(t, err)
+	assert.Len(t, fileList, 1)
+
+	filesToCheck["files"] = []string{testFileName, testDirName}
+	asJSON, err = json.Marshal(filesToCheck)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, webClientExistPath+"?path=%2F", bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	setCSRFHeaderForReq(req, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	fileList = nil
+	err = json.Unmarshal(rr.Body.Bytes(), &fileList)
+	assert.NoError(t, err)
+	assert.Len(t, fileList, 2)
+
+	req, err = http.NewRequest(http.MethodPost, webClientExistPath+"?path=%2F"+testDirName, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	setCSRFHeaderForReq(req, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	fileList = nil
+	err = json.Unmarshal(rr.Body.Bytes(), &fileList)
+	assert.NoError(t, err)
+	assert.Len(t, fileList, 0)
+
+	user.Filters.DeniedProtocols = []string{common.ProtocolHTTP}
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, webClientExistPath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	setCSRFHeaderForReq(req, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestWebClientViewPDF(t *testing.T) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)

+ 6 - 6
internal/httpd/internal_test.go

@@ -2858,35 +2858,35 @@ func TestBrowsableSharePaths(t *testing.T) {
 	}
 	req, err := http.NewRequest(http.MethodGet, "/share", nil)
 	require.NoError(t, err)
-	name, err := getBrowsableSharedPath(share, req)
+	name, err := getBrowsableSharedPath(share.Paths[0], req)
 	assert.NoError(t, err)
 	assert.Equal(t, "/", name)
 	req, err = http.NewRequest(http.MethodGet, "/share?path=abc", nil)
 	require.NoError(t, err)
-	name, err = getBrowsableSharedPath(share, req)
+	name, err = getBrowsableSharedPath(share.Paths[0], req)
 	assert.NoError(t, err)
 	assert.Equal(t, "/abc", name)
 
 	share.Paths = []string{"/a/b/c"}
 	req, err = http.NewRequest(http.MethodGet, "/share?path=abc", nil)
 	require.NoError(t, err)
-	name, err = getBrowsableSharedPath(share, req)
+	name, err = getBrowsableSharedPath(share.Paths[0], req)
 	assert.NoError(t, err)
 	assert.Equal(t, "/a/b/c/abc", name)
 	req, err = http.NewRequest(http.MethodGet, "/share?path=%2Fabc/d", nil)
 	require.NoError(t, err)
-	name, err = getBrowsableSharedPath(share, req)
+	name, err = getBrowsableSharedPath(share.Paths[0], req)
 	assert.NoError(t, err)
 	assert.Equal(t, "/a/b/c/abc/d", name)
 
 	req, err = http.NewRequest(http.MethodGet, "/share?path=%2Fabc%2F..%2F..", nil)
 	require.NoError(t, err)
-	_, err = getBrowsableSharedPath(share, req)
+	_, err = getBrowsableSharedPath(share.Paths[0], req)
 	assert.Error(t, err)
 
 	req, err = http.NewRequest(http.MethodGet, "/share?path=%2Fabc%2F..", nil)
 	require.NoError(t, err)
-	name, err = getBrowsableSharedPath(share, req)
+	name, err = getBrowsableSharedPath(share.Paths[0], req)
 	assert.NoError(t, err)
 	assert.Equal(t, "/a/b/c", name)
 

+ 4 - 2
internal/httpd/server.go

@@ -1233,7 +1233,6 @@ func (s *httpdServer) initializeRouter() {
 			ContentSecurityPolicy:   s.binding.Security.ContentSecurityPolicy,
 			PermissionsPolicy:       s.binding.Security.PermissionsPolicy,
 			CrossOriginOpenerPolicy: s.binding.Security.CrossOriginOpenerPolicy,
-			ExpectCTHeader:          s.binding.Security.ExpectCTHeader,
 		})
 		secureMiddleware.SetBadHostHandler(http.HandlerFunc(s.badHostHandler))
 		s.router.Use(secureMiddleware.Handler)
@@ -1541,6 +1540,7 @@ func (s *httpdServer) setupWebClientRoutes() {
 		s.router.Get(webClientPubSharesPath+"/{id}", s.downloadFromShare)
 		s.router.Post(webClientPubSharesPath+"/{id}/partial", s.handleClientSharePartialDownload)
 		s.router.Get(webClientPubSharesPath+"/{id}/browse", s.handleShareGetFiles)
+		s.router.Post(webClientPubSharesPath+"/{id}/browse/exist", s.handleClientShareCheckExist)
 		s.router.Get(webClientPubSharesPath+"/{id}/download", s.handleClientSharedFile)
 		s.router.Get(webClientPubSharesPath+"/{id}/upload", s.handleClientUploadToShare)
 		s.router.With(compressor.Handler).Get(webClientPubSharesPath+"/{id}/dirs", s.handleShareGetDirContents)
@@ -1563,6 +1563,8 @@ func (s *httpdServer) setupWebClientRoutes() {
 			router.With(s.checkAuthRequirements, s.refreshCookie, verifyCSRFHeader).Get(webClientFilePath, getUserFile)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Post(webClientFilePath, uploadUserFile)
+			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+				Post(webClientExistPath, s.handleClientCheckExist)
 			router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientEditFilePath, s.handleClientEditFile)
 			router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Delete(webClientFilesPath, deleteUserFile)
@@ -1578,7 +1580,7 @@ func (s *httpdServer) setupWebClientRoutes() {
 				Post(webClientFileActionsPath+"/copy", copyUserFsEntry)
 			router.With(s.checkAuthRequirements, s.refreshCookie).
 				Post(webClientDownloadZipPath, s.handleWebClientDownloadZip)
-			router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientPingPath, s.handleClientPing)
+			router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientPingPath, handlePingRequest)
 			router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientProfilePath,
 				s.handleClientGetProfile)
 			router.With(s.checkAuthRequirements).Post(webClientProfilePath, s.handleWebClientProfilePost)

+ 6 - 0
internal/httpd/web.go

@@ -20,6 +20,7 @@ import (
 	"net/http"
 	"strings"
 
+	"github.com/go-chi/render"
 	"github.com/unrolled/secure"
 
 	"github.com/drakkan/sftpgo/v2/internal/util"
@@ -148,3 +149,8 @@ func getI18NErrorString(err error, fallback string) string {
 	}
 	return fallback
 }
+
+func handlePingRequest(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	render.PlainText(w, r, "PONG")
+}

+ 82 - 6
internal/httpd/webclient.go

@@ -131,6 +131,7 @@ type filesPage struct {
 	CurrentDir         string
 	DirsURL            string
 	FileActionsURL     string
+	CheckExistURL      string
 	DownloadURL        string
 	ViewPDFURL         string
 	FileURL            string
@@ -802,6 +803,7 @@ func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Reque
 		DirsURL:            path.Join(baseSharePath, "dirs"),
 		FileURL:            "",
 		FileActionsURL:     "",
+		CheckExistURL:      path.Join(baseSharePath, "browse", "exist"),
 		CanAddFiles:        share.Scope == dataprovider.ShareScopeReadWrite,
 		CanCreateDirs:      false,
 		CanRename:          false,
@@ -843,6 +845,7 @@ func (s *httpdServer) renderFilesPage(w http.ResponseWriter, r *http.Request, di
 		DirsURL:            webClientDirsPath,
 		FileURL:            webClientFilePath,
 		FileActionsURL:     webClientFileActionsPath,
+		CheckExistURL:      webClientExistPath,
 		CanAddFiles:        user.CanAddFilesFromWeb(dirName),
 		CanCreateDirs:      user.CanAddDirsFromWeb(dirName),
 		CanRename:          user.CanRenameFromWeb(dirName, dirName),
@@ -955,7 +958,7 @@ func (s *httpdServer) handleClientSharePartialDownload(w http.ResponseWriter, r
 		s.renderClientMessagePage(w, r, util.I18nShareAccessErrorTitle, getRespStatus(err), err, "")
 		return
 	}
-	name, err := getBrowsableSharedPath(share, r)
+	name, err := getBrowsableSharedPath(share.Paths[0], r)
 	if err != nil {
 		s.renderClientMessagePage(w, r, util.I18nShareAccessErrorTitle, getRespStatus(err), err, "")
 		return
@@ -999,7 +1002,7 @@ func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.R
 		sendAPIResponse(w, r, err, getI18NErrorString(err, util.I18nError500Message), getRespStatus(err))
 		return
 	}
-	name, err := getBrowsableSharedPath(share, r)
+	name, err := getBrowsableSharedPath(share.Paths[0], r)
 	if err != nil {
 		sendAPIResponse(w, r, err, getI18NErrorString(err, util.I18nError500Message), getRespStatus(err))
 		return
@@ -1064,7 +1067,7 @@ func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request
 		s.renderClientMessagePage(w, r, util.I18nShareAccessErrorTitle, getRespStatus(err), err, "")
 		return
 	}
-	name, err := getBrowsableSharedPath(share, r)
+	name, err := getBrowsableSharedPath(share.Paths[0], r)
 	if err != nil {
 		s.renderClientMessagePage(w, r, util.I18nShareAccessErrorTitle, getRespStatus(err), err, "")
 		return
@@ -1131,7 +1134,7 @@ func (s *httpdServer) handleShareGetPDF(w http.ResponseWriter, r *http.Request)
 		s.renderClientMessagePage(w, r, util.I18nShareAccessErrorTitle, getRespStatus(err), err, "")
 		return
 	}
-	name, err := getBrowsableSharedPath(share, r)
+	name, err := getBrowsableSharedPath(share.Paths[0], r)
 	if err != nil {
 		s.renderClientMessagePage(w, r, util.I18nShareAccessErrorTitle, getRespStatus(err), err, "")
 		return
@@ -1919,9 +1922,82 @@ func (s *httpdServer) handleClientSharedFile(w http.ResponseWriter, r *http.Requ
 	s.renderShareDownloadPage(w, r, path.Join(webClientPubSharesPath, share.ShareID)+query)
 }
 
-func (s *httpdServer) handleClientPing(w http.ResponseWriter, r *http.Request) {
+func (s *httpdServer) handleClientCheckExist(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	render.PlainText(w, r, "PONG")
+	connection, err := getUserConnection(w, r)
+	if err != nil {
+		return
+	}
+	defer common.Connections.Remove(connection.GetID())
+
+	name := connection.User.GetCleanedPath(r.URL.Query().Get("path"))
+
+	doCheckExist(w, r, connection, name)
+}
+
+func (s *httpdServer) handleClientShareCheckExist(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeReadWrite}
+	share, connection, err := s.checkPublicShare(w, r, validScopes)
+	if err != nil {
+		return
+	}
+	if err := validateBrowsableShare(share, connection); err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	name, err := getBrowsableSharedPath(share.Paths[0], r)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+
+	if err = common.Connections.Add(connection); err != nil {
+		sendAPIResponse(w, r, err, "Unable to add connection", http.StatusTooManyRequests)
+		return
+	}
+	defer common.Connections.Remove(connection.GetID())
+
+	doCheckExist(w, r, connection, name)
+}
+
+type filesToCheck struct {
+	Files []string `json:"files"`
+}
+
+func doCheckExist(w http.ResponseWriter, r *http.Request, connection *Connection, name string) {
+	var filesList filesToCheck
+	err := render.DecodeJSON(r.Body, &filesList)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+	if len(filesList.Files) == 0 {
+		sendAPIResponse(w, r, errors.New("files to be checked are mandatory"), "", http.StatusBadRequest)
+		return
+	}
+
+	contents, err := connection.ListDir(name)
+	if err != nil {
+		sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err))
+		return
+	}
+	existing := make([]map[string]any, 0)
+	for _, info := range contents {
+		if util.Contains(filesList.Files, info.Name()) {
+			res := make(map[string]any)
+			res["name"] = info.Name()
+			if info.IsDir() {
+				res["type"] = "1"
+				res["size"] = ""
+			} else {
+				res["type"] = "2"
+				res["size"] = info.Size()
+			}
+			existing = append(existing, res)
+		}
+	}
+	render.JSON(w, r, existing)
 }
 
 func checkShareRedirectURL(next, base string) (bool, string) {

+ 1 - 0
internal/util/i18n.go

@@ -131,6 +131,7 @@ const (
 	I18nErrorNoPermissions             = "general.no_permissions"
 	I18nErrorShareBrowsePaths          = "share.browsable_multiple_paths"
 	I18nErrorShareBrowseNoDir          = "share.browsable_non_dir"
+	I18nErrorShareInvalidPath          = "share.invalid_path"
 	I18nErrorPathInvalid               = "general.path_invalid"
 	I18nErrorQuotaRead                 = "general.err_quota_read"
 	I18nErrorEditDir                   = "general.error_edit_dir"

+ 1 - 2
sftpgo.json

@@ -309,8 +309,7 @@
           "content_type_nosniff": false,
           "content_security_policy": "",
           "permissions_policy": "",
-          "cross_origin_opener_policy": "",
-          "expect_ct_header": ""
+          "cross_origin_opener_policy": ""
         },
         "branding": {
           "web_admin": {

+ 12 - 5
static/locales/en/translation.json

@@ -171,6 +171,7 @@
         "err_429": "Too many concurrent requests",
         "err_generic": "Unable to access the requested resource",
         "err_validation": "Invalid filesystem configuration",
+        "err_exists": "The destination already exists",
         "dir_list": {
             "err_generic": "Failed to get directory listing",
             "err_403": "$t(fs.dir_list.err_generic). $t(fs.err_403)",
@@ -204,20 +205,23 @@
             "msg": "Copy",
             "err_generic": "Error copying files/directories",
             "err_403": "$t(fs.copy.err_generic). $t(fs.err_403)",
-            "err_429": "$t(fs.copy.err_generic). $t(fs.err_429)"
+            "err_429": "$t(fs.copy.err_generic). $t(fs.err_429)",
+            "err_exists": "$t(fs.copy.err_generic). $t(fs.err_exists)"
         },
         "move": {
             "msg": "Move",
             "err_generic": "Error moving files/directories",
             "err_403": "$t(fs.move.err_generic). $t(fs.err_403)",
-            "err_429": "$t(fs.move.err_generic). $t(fs.err_429)"
+            "err_429": "$t(fs.move.err_generic). $t(fs.err_429)",
+            "err_exists": "$t(fs.move.err_generic). $t(fs.err_exists)"
         },
         "rename": {
             "title": "Rename \"{{- name}}\"",
             "new_name": "New name",
             "err_generic": "Unable to rename \"{{- name}}\"",
             "err_403": "$t(fs.rename.err_generic). $t(fs.err_403)",
-            "err_429": "$t(fs.rename.err_generic). $t(fs.err_429)"
+            "err_429": "$t(fs.rename.err_generic). $t(fs.err_429)",
+            "err_exists": "$t(fs.rename.err_generic). $t(fs.err_exists)"
         },
         "upload": {
             "text": "Upload Files",
@@ -226,7 +230,9 @@
             "message_empty": "This directory is empty. $t(fs.upload.message)",
             "err_generic": "Error uploading files",
             "err_403": "$t(fs.upload.err_generic). $t(fs.err_403)",
-            "err_429": "$t(fs.upload.err_generic). $t(fs.err_429)"
+            "err_429": "$t(fs.upload.err_generic). $t(fs.err_429)",
+            "err_dir_overwrite": "$t(fs.upload.err_generic). There are directories with the same name as the files: {{- val}}",
+            "overwrite_text": "File conflict detected. Do you want to overwrite the following files?"
         },
         "quota_usage": {
             "title": "Quota usage",
@@ -334,7 +340,8 @@
         "link_uncompressed_title": "Uncompressed file",
         "link_uncompressed_desc": "If the share consists of a single file, it can also be downloaded uncompressed",
         "upload_desc": "You can upload one or more files to the shared directory",
-        "expired_desc": "This share is no longer accessible because it has expired"
+        "expired_desc": "This share is no longer accessible because it has expired",
+        "invalid_path": "The shared directory is missing or not accessible"
     },
     "select2": {
         "no_results": "No results found",

+ 12 - 5
static/locales/it/translation.json

@@ -171,6 +171,7 @@
         "err_429": "Troppe richieste contemporanee",
         "err_generic": "Impossibile accedere alla risorsa richiesta",
         "err_validation": "Configurazione del filesystem non valida",
+        "err_exists": "La destinazione esiste già",
         "dir_list": {
             "err_generic": "Impossibile ottenere l'elenco della directory",
             "err_403": "$t(fs.dir_list.err_generic). $t(fs.err_403)",
@@ -204,20 +205,23 @@
             "msg": "Copia",
             "err_generic": "Errore copia file/cartelle",
             "err_403": "$t(fs.copy.err_generic). $t(fs.err_403)",
-            "err_429": "$t(fs.copy.err_generic). $t(fs.err_429)"
+            "err_429": "$t(fs.copy.err_generic). $t(fs.err_429)",
+            "err_exists": "$t(fs.copy.err_generic). $t(fs.err_exists)"
         },
         "move": {
             "msg": "Sposta",
             "err_generic": "Errore nello spostamento di file/directory",
             "err_403": "$t(fs.move.err_generic). $t(fs.err_403)",
-            "err_429": "$t(fs.move.err_generic). $t(fs.err_429)"
+            "err_429": "$t(fs.move.err_generic). $t(fs.err_429)",
+            "err_exists": "$t(fs.move.err_generic). $t(fs.err_exists)"
         },
         "rename": {
             "title": "Rinomina \"{{- name}}\"",
             "new_name": "Nuovo nome",
             "err_generic": "Impossibile rinominare \"{{- name}}\"",
             "err_403": "$t(fs.rename.err_generic): $t(fs.err_403)",
-            "err_429": "$t(fs.rename.err_generic): $t(fs.err_429)"
+            "err_429": "$t(fs.rename.err_generic): $t(fs.err_429)",
+            "err_exists": "$t(fs.rename.err_generic). $t(fs.err_exists)"
         },
         "upload": {
             "text": "Carica file",
@@ -226,7 +230,9 @@
             "message_empty": "Questa cartella è vuota. $t(fs.upload.message)",
             "err_generic": "Errore caricamento file",
             "err_403": "$t(fs.upload.err_generic). $t(fs.err_403)",
-            "err_429": "$t(fs.upload.err_generic). $t(fs.err_429)"
+            "err_429": "$t(fs.upload.err_generic). $t(fs.err_429)",
+            "err_dir_overwrite": "$t(fs.upload.err_generic). Ci sono cartelle con lo stesso nome dei file: {{- val}}",
+            "overwrite_text": "Rilevato conflitto di file. Vuoi sovrascrivere i seguenti file?"
         },
         "quota_usage": {
             "title": "Utilizzo quota",
@@ -334,7 +340,8 @@
         "link_uncompressed_title": "File non compresso",
         "link_uncompressed_desc": "Se la condivisione è costituita da un unico file è possibile scaricarlo anche non compresso",
         "upload_desc": "È possibile caricare uno o più file nella directory condivisa",
-        "expired_desc": "Questa condivisione non è più accessibile perché è scaduta"
+        "expired_desc": "Questa condivisione non è più accessibile perché è scaduta",
+        "invalid_path": "La directory condivisa manca o non è accessibile"
     },
     "select2": {
         "no_results": "Nessun risultato trovato",

+ 14 - 2
templates/webclient/base.html

@@ -64,7 +64,7 @@ explicit grant from the SFTPGo Team ([email protected]).
                                 {{- block "additionalnavitems" .}}{{- end}}
 								{{- if ne .CurrentURL .EditURL }}
                                 <div class="d-flex align-items-center ms-2 ms-lg-3">
-									<a href="#" class="btn btn-icon btn-active-light-primary w-35px h-35px w-md-40px h-md-40px" data-kt-menu-trigger="{default:'click', lg: 'hover'}" data-kt-menu-attach="parent" data-kt-menu-placement="bottom-end">
+									<a href="#" class="btn btn-icon btn-active-light-primary w-35px h-35px w-md-40px h-md-40px" data-kt-menu-trigger="click" data-kt-menu-attach="parent" data-kt-menu-placement="bottom-end">
 										<i class="ki-duotone ki-night-day theme-light-show fs-2">
 											<span class="path1"></span>
 											<span class="path2"></span>
@@ -130,7 +130,7 @@ explicit grant from the SFTPGo Team ([email protected]).
 								</div>
                                 {{- end}}
 								<div class="d-flex align-items-center ms-2 ms-lg-3">
-									<div class="btn btn-icon btn-active-light-primary w-35px h-35px w-md-40px h-md-40px" data-kt-menu-trigger="{default:'click', lg: 'hover'}" data-kt-menu-attach="parent" data-kt-menu-placement="bottom-end">
+									<div class="btn btn-icon btn-active-light-primary w-35px h-35px w-md-40px h-md-40px" data-kt-menu-trigger="click" data-kt-menu-attach="parent" data-kt-menu-placement="bottom-end">
 										<i class="ki-duotone ki-user fs-2">
                                             <i class="path1"></i>
                                             <i class="path2"></i>
@@ -285,6 +285,8 @@ explicit grant from the SFTPGo Team ([email protected]).
                                 <span id="modal_alert_text" class="fs-6 text-gray-900 fw-semibold"></span>
                             </div>
                         </div>
+                        <div id="modal_alert_items" class="d-flex flex-column mt-5 d-none">
+                        </div>
                     </div>
                     <div class="modal-footer border-0 justify-content-center">
                         <button id="modal_alert_cancel" type="button" class="btn btn-secondary m-2"></button>
@@ -355,6 +357,7 @@ explicit grant from the SFTPGo Team ([email protected]).
                         let modalEl = $('#modal_alert');
                         let okBtn = $("#modal_alert_ok");
                         let cancelBtn = $("#modal_alert_cancel");
+                        let itemsList = $('#modal_alert_items');
 
                         modalEl.off('hide.bs.modal');
                         modalEl.on('hide.bs.modal', hideFn);
@@ -376,6 +379,15 @@ explicit grant from the SFTPGo Team ([email protected]).
                         }
 
                         $("#modal_alert_text").text(params.text);
+                        itemsList.empty();
+                        itemsList.addClass("d-none");
+                        if (params.items && params.items.length > 0){
+                            itemsList.removeClass("d-none");
+                            $.each(params.items, function(key, item) {
+                                itemText = escapeHTML(item);
+                                itemsList.append(`<li class="d-flex align-items-center py-2 fw-bold fs-6 text-gray-800"><span class="bullet bullet-dot me-5"></span>${itemText}</li>`);
+                            });
+                        }
 
                         switch (params.icon){
                             case "warning":

+ 189 - 29
templates/webclient/files.html

@@ -1168,6 +1168,7 @@ explicit grant from the SFTPGo Team ([email protected]).
         }
 
         if ($('#move_copy_name_container').hasClass("d-none")){
+            // bulk action
             let dt = $('#file_manager_list').DataTable();
             dt.rows({ selected: true, search: 'applied' }).every(function (rowIdx, tableLoop, rowLoop){
                 let row = dt.row(rowIdx);
@@ -1283,7 +1284,27 @@ explicit grant from the SFTPGo Team ([email protected]).
             });
         }
 
-        copyItem();
+        let filesArray = [];
+        for (let i = 0; i < items.length; i++){
+            filesArray.push({
+                name: items[i].targetName
+            });
+        }
+
+        CheckExist.fire({
+            operation: "copy",
+            files: filesArray,
+            path: items[0].targetDir
+        }).then((result)=>{
+            if (result.error) {
+                hasError = true;
+                showToast("fs.copy.err_generic");
+            } else if (result.data.length > 0){
+                hasError = true;
+                showToast("fs.copy.err_exists");
+            }
+            copyItem();
+        });
     }
 
     function doMove() {
@@ -1371,7 +1392,27 @@ explicit grant from the SFTPGo Team ([email protected]).
             });
         }
 
-        moveItem();
+        let filesArray = [];
+        for (let i = 0; i < items.length; i++){
+            filesArray.push({
+                name: items[i].targetName
+            });
+        }
+
+        CheckExist.fire({
+            operation: "move",
+            files: filesArray,
+            path: items[0].targetDir
+        }).then((result)=>{
+            if (result.error) {
+                hasError = true;
+                showToast("fs.move.err_generic");
+            } else if (result.data.length > 0){
+                hasError = true;
+                showToast("fs.move.err_exists");
+            }
+            moveItem();
+        });
     }
 
     function getDeleteReqAttrs(meta) {
@@ -1476,34 +1517,59 @@ explicit grant from the SFTPGo Team ([email protected]).
             showToast("fs.invalid_name");
             return;
         }
-        let path = '{{.FileActionsURL}}/move';
-        path+='?path={{.CurrentDir}}'+encodeURIComponent("/"+oldName)+'&target={{.CurrentDir}}'+encodeURIComponent("/"+newName);
-        axios.post(path, null, {
-            timeout: 15000,
-            headers: {
-                'X-CSRF-TOKEN': '{{.CSRFToken}}'
-            },
-            validateStatus: function (status) {
-                return status == 200;
-            }
-        }).then(function (response) {
-            location.reload();
-        }).catch(function (error) {
-            let errorMessage;
-            if (error && error.response) {
-                switch (error.response.status) {
-                    case 403:
-                        errorMessage = "fs.rename.err_403";
-                        break;
-                    case 429:
-                        errorMessage = "fs.rename.err_429";
-                        break;
+
+        $('#loading_message').text("");
+        KTApp.showPageLoading();
+
+        function executeRename() {
+            let path = '{{.FileActionsURL}}/move';
+            path += '?path={{.CurrentDir}}' + encodeURIComponent("/" + oldName) + '&target={{.CurrentDir}}' + encodeURIComponent("/" + newName);
+            axios.post(path, null, {
+                timeout: 15000,
+                headers: {
+                    'X-CSRF-TOKEN': '{{.CSRFToken}}'
+                },
+                validateStatus: function (status) {
+                    return status == 200;
+                }
+            }).then(function (response) {
+                location.reload();
+            }).catch(function (error) {
+                KTApp.hidePageLoading();
+                let errorMessage;
+                if (error && error.response) {
+                    switch (error.response.status) {
+                        case 403:
+                            errorMessage = "fs.rename.err_403";
+                            break;
+                        case 429:
+                            errorMessage = "fs.rename.err_429";
+                            break;
+                    }
+                }
+                if (!errorMessage) {
+                    errorMessage = "fs.rename.err_generic";
                 }
+                showToast(errorMessage, { name: oldName });
+            });
+        }
+
+        CheckExist.fire({
+            operation: "move",
+            files: [{name: newName}],
+            path: '{{.CurrentDir}}'
+        }).then((result)=>{
+            if (result.error) {
+                KTApp.hidePageLoading();
+                showToast("fs.rename.err_generic", { name: oldName });
+                return;
             }
-            if (!errorMessage){
-                errorMessage = "fs.rename.err_generic";
+            if (result.data.length > 0){
+                KTApp.hidePageLoading();
+                showToast("fs.rename.err_exists", { name: oldName });
+                return;
             }
-            showToast(errorMessage, {name: oldName});
+            executeRename();
         });
     }
 
@@ -1686,9 +1752,103 @@ explicit grant from the SFTPGo Team ([email protected]).
             });
         }
 
-        uploadFile();
+        CheckExist.fire({
+            operation: "upload",
+            files: files,
+            path: "{{.CurrentDir}}"
+        }).then((result)=> {
+            if (result.error) {
+                has_errors = true;
+                setI18NData($('#errorTxt'), "fs.upload.err_generic");
+                $('#errorMsg').removeClass("d-none");
+                uploadFile();
+                return;
+            }
+            let existingFiles = [];
+            let existingDirs = [];
+            $.each(result.data, function (key, item) {
+                if (item.type === "1") {
+                    existingDirs.push(item.name);
+                } else {
+                    existingFiles.push(item.name);
+                }
+            });
+            if (existingDirs.length > 0) {
+                has_errors = true;
+                setI18NData($('#errorTxt'), "fs.upload.err_dir_overwrite", {val: existingDirs.join(", ")});
+                $('#errorMsg').removeClass("d-none");
+                uploadFile();
+                return;
+            }
+            if (existingFiles.length > 0) {
+                KTApp.hidePageLoading();
+                ModalAlert.fire({
+                    text: $.t('fs.upload.overwrite_text'),
+                    items: existingFiles,
+                    icon: "warning",
+                    confirmButtonText: $.t('general.confirm'),
+                    cancelButtonText: $.t('general.cancel'),
+                    customClass: {
+                        confirmButton: "btn btn-danger",
+                        cancelButton: 'btn btn-secondary'
+                    }
+                }).then((result) => {
+                    if (result.isConfirmed){
+                        KTApp.showPageLoading();
+                    } else {
+                        has_errors = true;
+                    }
+                    uploadFile();
+                });
+                return;
+            }
+            uploadFile();
+        });
     }
 
+    var CheckExist = function () {
+            var promiseResolve;
+
+            function doCheck(operation, files, target) {
+                let filesArray = [];
+                if (files && files.length > 0){
+                    for (let i = 0; i < files.length; i++){
+                        filesArray.push(files[i].name);
+                    }
+                }
+                let path = '{{.CheckExistURL}}?op='+encodeURIComponent(operation)+"&path="+target;
+                axios.post(path, {
+                    files: filesArray
+                }, {
+                    headers: {
+                        timeout: 15000,
+                        'X-CSRF-TOKEN': '{{.CSRFToken}}'
+                    },
+                    validateStatus: function (status) {
+                        return status == 200;
+                    }
+                }).then(function(response){
+                    promiseResolve({
+                        error: false,
+                        data: response.data
+                    });
+                }).catch(function(error){
+                    promiseResolve({
+                        error: true
+                    });
+                });
+            }
+
+            return {
+                fire: function (params) {
+                    return new Promise(function (resolve, reject) {
+                        promiseResolve = resolve;
+                        doCheck(params.operation, params.files, params.path);
+                    });
+                }
+            }
+        }();
+
     function openMediaPlayer(name, url){
         $("#video_title").text(name);
         $("#video_player").attr("src", url);
@@ -1830,7 +1990,7 @@ explicit grant from the SFTPGo Team ([email protected]).
 {{- define "additionalnavitems"}}
 {{- if .QuotaUsage.HasQuotaInfo}}
 <div class="d-flex align-items-center ms-2 ms-lg-3">
-    <div class="btn btn-icon btn-active-light-primary position-relative w-35px h-35px w-md-40px h-md-40px" data-kt-menu-trigger="{default:'click', lg: 'hover'}" data-kt-menu-attach="parent" data-kt-menu-placement="bottom-end">
+    <div class="btn btn-icon btn-active-light-primary position-relative w-35px h-35px w-md-40px h-md-40px" data-kt-menu-trigger="click" data-kt-menu-attach="parent" data-kt-menu-placement="bottom-end">
         <i class="ki-duotone {{if .QuotaUsage.IsQuotaLow}}ki-information-5 text-warning{{else}}ki-information-2{{end}} fs-2">
             <span class="path1"></span>
             <span class="path2"></span>