WebClient: do not silently overwrite files/directories

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2023-12-28 18:43:07 +01:00
parent e35e07acdb
commit 3121c35437
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
20 changed files with 564 additions and 90 deletions

View file

@ -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

6
go.mod
View file

@ -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

12
go.sum
View file

@ -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=

View file

@ -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
}

View file

@ -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
}

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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) {

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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")
}

View file

@ -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) {

View file

@ -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"

View file

@ -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": {

View file

@ -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",

View file

@ -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",

View file

@ -64,7 +64,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
{{- 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 (support@sftpgo.com).
</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 (support@sftpgo.com).
<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 (support@sftpgo.com).
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 (support@sftpgo.com).
}
$("#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":

View file

@ -1168,6 +1168,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
}
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 (support@sftpgo.com).
});
}
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 (support@sftpgo.com).
});
}
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 (support@sftpgo.com).
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 (support@sftpgo.com).
});
}
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 (support@sftpgo.com).
{{- 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>