From 3121c3543760f829f13963a486408c99e3fa67ee Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Thu, 28 Dec 2023 18:43:07 +0100 Subject: [PATCH] WebClient: do not silently overwrite files/directories Signed-off-by: Nicola Murino --- docs/full-configuration.md | 1 - go.mod | 6 +- go.sum | 12 +- internal/common/connection.go | 2 +- internal/config/config.go | 7 - internal/config/config_test.go | 3 - internal/httpd/api_shares.go | 17 ++- internal/httpd/api_utils.go | 4 +- internal/httpd/httpd.go | 7 +- internal/httpd/httpd_test.go | 211 ++++++++++++++++++++++++++++ internal/httpd/internal_test.go | 12 +- internal/httpd/server.go | 6 +- internal/httpd/web.go | 6 + internal/httpd/webclient.go | 88 +++++++++++- internal/util/i18n.go | 1 + sftpgo.json | 3 +- static/locales/en/translation.json | 17 ++- static/locales/it/translation.json | 17 ++- templates/webclient/base.html | 16 ++- templates/webclient/files.html | 218 +++++++++++++++++++++++++---- 20 files changed, 564 insertions(+), 90 deletions(-) diff --git a/docs/full-configuration.md b/docs/full-configuration.md index a432538d..4ff75d1a 100644 --- a/docs/full-configuration.md +++ b/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 diff --git a/go.mod b/go.mod index 26f50125..a03cb379 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index b49c5e0c..9952ffbc 100644 --- a/go.sum +++ b/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= diff --git a/internal/common/connection.go b/internal/common/connection.go index 6de1f5bb..3258c6a7 100644 --- a/internal/common/connection.go +++ b/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 } diff --git a/internal/config/config.go b/internal/config/config.go index e2717ec6..cb375271 100644 --- a/internal/config/config.go +++ b/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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1e6945f7..f581ca22 100644 --- a/internal/config/config_test.go +++ b/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) diff --git a/internal/httpd/api_shares.go b/internal/httpd/api_shares.go index e4b61d3f..e965e3c1 100644 --- a/internal/httpd/api_shares.go +++ b/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, diff --git a/internal/httpd/api_utils.go b/internal/httpd/api_utils.go index 88447647..ee85ff4e 100644 --- a/internal/httpd/api_utils.go +++ b/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 diff --git a/internal/httpd/httpd.go b/internal/httpd/httpd.go index 5957a746..681dde9d 100644 --- a/internal/httpd/httpd.go +++ b/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) { diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index 77eb176e..4fd93156 100644 --- a/internal/httpd/httpd_test.go +++ b/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) diff --git a/internal/httpd/internal_test.go b/internal/httpd/internal_test.go index 593b3f8c..272f022e 100644 --- a/internal/httpd/internal_test.go +++ b/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) diff --git a/internal/httpd/server.go b/internal/httpd/server.go index e80b65c2..dd23d989 100644 --- a/internal/httpd/server.go +++ b/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) diff --git a/internal/httpd/web.go b/internal/httpd/web.go index 4c20aa84..ea3d25b2 100644 --- a/internal/httpd/web.go +++ b/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") +} diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go index ac9925bc..b5f4dbed 100644 --- a/internal/httpd/webclient.go +++ b/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) { diff --git a/internal/util/i18n.go b/internal/util/i18n.go index b56bc466..99f2498e 100644 --- a/internal/util/i18n.go +++ b/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" diff --git a/sftpgo.json b/sftpgo.json index ff973c68..5f02c585 100644 --- a/sftpgo.json +++ b/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": { diff --git a/static/locales/en/translation.json b/static/locales/en/translation.json index e872e7a0..229dbff6 100644 --- a/static/locales/en/translation.json +++ b/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", diff --git a/static/locales/it/translation.json b/static/locales/it/translation.json index 629d6859..f0b19ec6 100644 --- a/static/locales/it/translation.json +++ b/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", diff --git a/templates/webclient/base.html b/templates/webclient/base.html index 10fb4d9b..1c6b4a01 100644 --- a/templates/webclient/base.html +++ b/templates/webclient/base.html @@ -64,7 +64,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). {{- block "additionalnavitems" .}}{{- end}} {{- if ne .CurrentURL .EditURL }} {{- end}}
-
+
@@ -285,6 +285,8 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
+