From 423d8306be0195de04aa4433d40a9715d4378468 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Sun, 30 May 2021 23:07:46 +0200 Subject: [PATCH] webclient: allow to download multiple files as zip --- docs/full-configuration.md | 4 +- docs/web-client.md | 3 +- go.mod | 5 +- go.sum | 10 +- httpd/api_utils.go | 76 + httpd/httpd.go | 3 + httpd/httpd_test.go | 47 + httpd/internal_test.go | 136 ++ httpd/middleware.go | 27 + httpd/server.go | 3 +- httpd/webclient.go | 50 +- sftpd/ssh_cmd.go | 6 +- .../datatables/dataTables.checkboxes.css | 1330 +++++++++++++++++ .../datatables/dataTables.checkboxes.min.js | 3 + templates/webclient/files.html | 277 ++-- utils/utils.go | 10 +- vfs/osfs.go | 6 +- webdavd/internal_test.go | 12 +- 18 files changed, 1869 insertions(+), 139 deletions(-) create mode 100644 static/vendor/datatables/dataTables.checkboxes.css create mode 100644 static/vendor/datatables/dataTables.checkboxes.min.js diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 546ef22a..6ed30b7e 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -149,7 +149,7 @@ The configuration file contains the following sections: - `client_auth_type`, integer. Set to `1` to require a client certificate and verify it. Set to `2` to request a client certificate during the TLS handshake and verify it if given, in this mode the client is allowed not to send a certificate. At least one certification authority must be defined in order to verify client certificates. If no certification authority is defined, this setting is ignored. Default: 0. - `tls_cipher_suites`, list of strings. List of supported cipher suites for TLS version 1.2. If empty, a default list of secure cipher suites is used, with a preference order based on hardware performance. Note that TLS 1.3 ciphersuites are not configurable. The supported ciphersuites names are defined [here](https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L52). Any invalid name will be silently ignored. The order matters, the ciphers listed first will be the preferred ones. Default: empty. - `prefix`, string. Prefix for WebDAV resources, if empty WebDAV resources will be available at the `/` URI. If defined it must be an absolute URI, for example `/dav`. Default: "". - - `proxy_allowed`, list of IP addresses and IP ranges allowed to set `X-Forwarded-For`, `X-Real-IP` headers. Any of the indicated headers, if set on requests from a connection address not in this list, will be silently ignored. Default: empty. + - `proxy_allowed`, list of IP addresses and IP ranges allowed to set `X-Forwarded-For`, `X-Real-IP`, `CF-Connecting-IP`, `True-Client-IP` headers. Any of the indicated headers, if set on requests from a connection address not in this list, will be silently ignored. Default: empty. - `bind_port`, integer. Deprecated, please use `bindings`. - `bind_address`, string. Deprecated, please use `bindings`. - `certificate_file`, string. Certificate for WebDAV over HTTPS. This can be an absolute path or a path relative to the config dir. @@ -220,7 +220,7 @@ The configuration file contains the following sections: - `enable_https`, boolean. Set to `true` and provide both a certificate and a key file to enable HTTPS connection for this binding. Default `false`. - `client_auth_type`, integer. Set to `1` to require client certificate authentication in addition to JWT/Web authentication. You need to define at least a certificate authority for this to work. Default: 0. - `tls_cipher_suites`, list of strings. List of supported cipher suites for TLS version 1.2. If empty, a default list of secure cipher suites is used, with a preference order based on hardware performance. Note that TLS 1.3 ciphersuites are not configurable. The supported ciphersuites names are defined [here](https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L52). Any invalid name will be silently ignored. The order matters, the ciphers listed first will be the preferred ones. Default: empty. - - `proxy_allowed`, list of IP addresses and IP ranges allowed to set `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto` headers. Any of the indicated headers, if set on requests from a connection address not in this list, will be silently ignored. Default: empty. + - `proxy_allowed`, list of IP addresses and IP ranges allowed to set `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto`, `CF-Connecting-IP`, `True-Client-IP` headers. Any of the indicated headers, if set on requests from a connection address not in this list, will be silently ignored. Default: empty. - `bind_port`, integer. Deprecated, please use `bindings`. - `bind_address`, string. Deprecated, please use `bindings`. Leave blank to listen on all available network interfaces. On \*NIX you can specify an absolute path to listen on a Unix-domain socket. Default: "" - `templates_path`, string. Path to the HTML web templates. This can be an absolute path or a path relative to the config dir diff --git a/docs/web-client.md b/docs/web-client.md index a166f8ae..8465fe1f 100644 --- a/docs/web-client.md +++ b/docs/web-client.md @@ -4,7 +4,8 @@ SFTPGo provides a basic front-end web interface for your users. It allows end-us The web interface can be globally disabled within the `httpd` configuration via the `enable_web_client` key or on a per-user basis by adding `HTTP` to the denied protocols. Public keys management can be disabled, per-user, using a specific permission. +The web client allows you to download multiple files or folders as a single zip file, any non regular files (for example symlinks) will be silently ignored. -With the default `httpd` configuration, the web admin is available at the following URL: +With the default `httpd` configuration, the web client is available at the following URL: [http://127.0.0.1:8080/web/client](http://127.0.0.1:8080/web/client) diff --git a/go.mod b/go.mod index 78e81623..80dd740c 100644 --- a/go.mod +++ b/go.mod @@ -8,10 +8,10 @@ require ( github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 github.com/StackExchange/wmi v0.0.0-20210224194228-fe8f1750fd46 // indirect github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8 - github.com/aws/aws-sdk-go v1.38.49 + github.com/aws/aws-sdk-go v1.38.51 github.com/cockroachdb/cockroach-go/v2 v2.1.1 github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect - github.com/eikenb/pipeat v0.0.0-20200430215831-470df5986b6d + github.com/eikenb/pipeat v0.0.0-20210528001815-f1fcb2512a3a github.com/fclairamb/ftpserverlib v0.13.1 github.com/frankban/quicktest v1.13.0 // indirect github.com/go-chi/chi/v5 v5.0.3 @@ -25,6 +25,7 @@ require ( github.com/grandcat/zeroconf v1.0.0 github.com/hashicorp/go-retryablehttp v0.7.0 github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126 + github.com/klauspost/compress v1.12.3 github.com/klauspost/cpuid/v2 v2.0.6 // indirect github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect github.com/lestrrat-go/jwx v1.2.0 diff --git a/go.sum b/go.sum index b8a72c93..20e938ae 100644 --- a/go.sum +++ b/go.sum @@ -127,8 +127,8 @@ github.com/aws/aws-sdk-go v1.25.37/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpi github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.30.27/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= -github.com/aws/aws-sdk-go v1.38.49 h1:E31vxjCe6a5I+mJLmUGaZobiWmg9KdWaud9IfceYeYQ= -github.com/aws/aws-sdk-go v1.38.49/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go v1.38.51 h1:aKQmbVbwOCuQSd8+fm/MR3bq0QOsu9Q7S+/QEND36oQ= +github.com/aws/aws-sdk-go v1.38.51/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= @@ -215,8 +215,8 @@ github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= -github.com/eikenb/pipeat v0.0.0-20200430215831-470df5986b6d h1:8RvCRWer7TB2n+DKhW4uW15hRiqPmabSnSyYhju/Nuw= -github.com/eikenb/pipeat v0.0.0-20200430215831-470df5986b6d/go.mod h1:+JPhBw5JdJrSF80r6xsSg1TYHjyAGxYs4X24VyUdMZU= +github.com/eikenb/pipeat v0.0.0-20210528001815-f1fcb2512a3a h1:+btFAKG3kNCqm1DMKDGaWkolX/4aytcbvnfdgt6z+UI= +github.com/eikenb/pipeat v0.0.0-20210528001815-f1fcb2512a3a/go.mod h1:+JPhBw5JdJrSF80r6xsSg1TYHjyAGxYs4X24VyUdMZU= github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -544,6 +544,8 @@ github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQL github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU= +github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.6 h1:dQ5ueTiftKxp0gyjKSx5+8BtPWkyQbd95m8Gys/RarI= github.com/klauspost/cpuid/v2 v2.0.6/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= diff --git a/httpd/api_utils.go b/httpd/api_utils.go index 640e35bb..8a3e10b1 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -3,14 +3,19 @@ package httpd import ( "context" "errors" + "io" "net/http" "os" + "path" "strconv" + "strings" "github.com/go-chi/render" + "github.com/klauspost/compress/zip" "github.com/drakkan/sftpgo/common" "github.com/drakkan/sftpgo/dataprovider" + "github.com/drakkan/sftpgo/logger" ) func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message string, code int) { @@ -90,3 +95,74 @@ func getSearchFilters(w http.ResponseWriter, r *http.Request) (int, int, string, return limit, offset, order, err } + +func renderCompressedFiles(w http.ResponseWriter, conn *Connection, baseDir string, files []string) { + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Accept-Ranges", "none") + w.Header().Set("Content-Transfer-Encoding", "binary") + w.WriteHeader(http.StatusOK) + + wr := zip.NewWriter(w) + + for _, file := range files { + fullPath := path.Join(baseDir, file) + if err := addZipEntry(wr, conn, fullPath, baseDir); err != nil { + panic(http.ErrAbortHandler) + } + } + if err := wr.Close(); err != nil { + conn.Log(logger.LevelWarn, "unable to close zip file: %v", err) + panic(http.ErrAbortHandler) + } +} + +func addZipEntry(wr *zip.Writer, conn *Connection, entryPath, baseDir string) error { + info, err := conn.Stat(entryPath, 1) + if err != nil { + conn.Log(logger.LevelDebug, "unable to add zip entry %#v, stat error: %v", entryPath, err) + return err + } + if info.IsDir() { + _, err := wr.Create(getZipEntryName(entryPath, baseDir) + "/") + if err != nil { + conn.Log(logger.LevelDebug, "unable to create zip entry %#v: %v", entryPath, err) + return err + } + contents, err := conn.ReadDir(entryPath) + if err != nil { + conn.Log(logger.LevelDebug, "unable to add zip entry %#v, read dir error: %v", entryPath, err) + return err + } + for _, info := range contents { + fullPath := path.Join(entryPath, info.Name()) + if err := addZipEntry(wr, conn, fullPath, baseDir); err != nil { + return err + } + } + return nil + } + if !info.Mode().IsRegular() { + // we only allow regular files + conn.Log(logger.LevelDebug, "skipping zip entry for non regular file %#v", entryPath) + return nil + } + reader, err := conn.getFileReader(entryPath, 0, http.MethodGet) + if err != nil { + conn.Log(logger.LevelDebug, "unable to add zip entry %#v, cannot open file: %v", entryPath, err) + return err + } + defer reader.Close() + + f, err := wr.Create(getZipEntryName(entryPath, baseDir)) + if err != nil { + conn.Log(logger.LevelDebug, "unable to create zip entry %#v: %v", entryPath, err) + return err + } + _, err = io.Copy(f, reader) + return err +} + +func getZipEntryName(entryPath, baseDir string) string { + entryPath = strings.TrimPrefix(entryPath, baseDir) + return strings.TrimPrefix(entryPath, "/") +} diff --git a/httpd/httpd.go b/httpd/httpd.go index 00d43d14..feac5c5d 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -75,6 +75,7 @@ const ( webClientLoginPathDefault = "/web/client/login" webClientFilesPathDefault = "/web/client/files" webClientDirContentsPathDefault = "/web/client/listdir" + webClientDownloadPathDefault = "/web/client/download" webClientCredentialsPathDefault = "/web/client/credentials" webChangeClientPwdPathDefault = "/web/client/changepwd" webChangeClientKeysPathDefault = "/web/client/managekeys" @@ -120,6 +121,7 @@ var ( webClientLoginPath string webClientFilesPath string webClientDirContentsPath string + webClientDownloadPath string webClientCredentialsPath string webChangeClientPwdPath string webChangeClientKeysPath string @@ -415,6 +417,7 @@ func updateWebClientURLs(baseURL string) { webClientLoginPath = path.Join(baseURL, webClientLoginPathDefault) webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault) webClientDirContentsPath = path.Join(baseURL, webClientDirContentsPathDefault) + webClientDownloadPath = path.Join(baseURL, webClientDownloadPathDefault) webClientCredentialsPath = path.Join(baseURL, webClientCredentialsPathDefault) webChangeClientPwdPath = path.Join(baseURL, webChangeClientPwdPathDefault) webChangeClientKeysPath = path.Join(baseURL, webChangeClientKeysPathDefault) diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 8020d578..bb10b0b0 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -92,6 +92,7 @@ const ( webClientLoginPath = "/web/client/login" webClientFilesPath = "/web/client/files" webClientDirContentsPath = "/web/client/listdir" + webClientDownloadPath = "/web/client/download" webClientCredentialsPath = "/web/client/credentials" webChangeClientPwdPath = "/web/client/changepwd" webChangeClientKeysPath = "/web/client/managekeys" @@ -4576,6 +4577,12 @@ func TestWebClientLoginMock(t *testing.T) { checkResponseCode(t, http.StatusInternalServerError, rr) assert.Contains(t, rr.Body.String(), "unable to retrieve your user") + req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + assert.Contains(t, rr.Body.String(), "Unable to retrieve your user") + csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath) assert.NoError(t, err) form := make(url.Values) @@ -4993,6 +5000,24 @@ func TestWebGetFiles(t *testing.T) { assert.NoError(t, err) assert.Len(t, dirContents, 1) + req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath+"?path="+url.QueryEscape("/")+"&files="+ + url.QueryEscape(fmt.Sprintf(`["%v","%v","%v"]`, testFileName, testDir, testFileName+extensions[2])), nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath+"?path="+url.QueryEscape("/")+"&files="+ + url.QueryEscape(fmt.Sprintf(`["%v"]`, testDir)), nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath+"?path="+url.QueryEscape("/")+"&files=notalist", nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + assert.Contains(t, rr.Body.String(), "Unable to get files list") + req, _ = http.NewRequest(http.MethodGet, webClientDirContentsPath+"?path=/", nil) setJWTCookieForReq(req, webToken) rr = executeRequest(req) @@ -5102,6 +5127,28 @@ func TestWebGetFiles(t *testing.T) { assert.NoError(t, err) } +func TestCompressionErrorMock(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + defer func() { + rcv := recover() + assert.Equal(t, http.ErrAbortHandler, rcv) + _, err := httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + }() + + webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + req, _ := http.NewRequest(http.MethodGet, webClientDownloadPath+"?path="+url.QueryEscape("/")+"&files="+ + url.QueryEscape(`["missing"]`), nil) + setJWTCookieForReq(req, webToken) + executeRequest(req) +} + func TestGetFilesSFTPBackend(t *testing.T) { user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 340224da..d4b3403e 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -22,6 +22,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/jwtauth/v5" + "github.com/klauspost/compress/zip" "github.com/lestrrat-go/jwx/jwa" "github.com/lestrrat-go/jwx/jwt" "github.com/rs/xid" @@ -263,6 +264,19 @@ xr5cb9VBRBtB9aOKVfuRhpatAfS2Pzm2Htae9lFn7slGPUmu2hkjDw== -----END RSA PRIVATE KEY-----` ) +type failingWriter struct { +} + +func (r *failingWriter) Write(p []byte) (n int, err error) { + return 0, errors.New("write error") +} + +func (r *failingWriter) WriteHeader(statusCode int) {} + +func (r *failingWriter) Header() http.Header { + return make(http.Header) +} + func TestShouldBind(t *testing.T) { c := Conf{ Bindings: []Binding{ @@ -1088,6 +1102,119 @@ func TestProxyHeaders(t *testing.T) { assert.NoError(t, err) } +func TestRecoverer(t *testing.T) { + recoveryPath := "/recovery" + b := Binding{ + Address: "", + Port: 8080, + EnableWebAdmin: true, + EnableWebClient: false, + } + server := newHttpdServer(b, "../static") + server.initializeRouter() + server.router.Get(recoveryPath, func(w http.ResponseWriter, r *http.Request) { + panic("panic") + }) + testServer := httptest.NewServer(server.router) + defer testServer.Close() + + req, err := http.NewRequest(http.MethodGet, recoveryPath, nil) + assert.NoError(t, err) + rr := httptest.NewRecorder() + testServer.Config.Handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String()) + + server.router = chi.NewRouter() + server.router.Use(recoverer) + server.router.Get(recoveryPath, func(w http.ResponseWriter, r *http.Request) { + panic("panic") + }) + testServer = httptest.NewServer(server.router) + defer testServer.Close() + + req, err = http.NewRequest(http.MethodGet, recoveryPath, nil) + assert.NoError(t, err) + rr = httptest.NewRecorder() + testServer.Config.Handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String()) +} + +func TestCompressorAbortHandler(t *testing.T) { + defer func() { + rcv := recover() + assert.Equal(t, http.ErrAbortHandler, rcv) + }() + + connection := &Connection{ + BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, dataprovider.User{}), + request: nil, + } + renderCompressedFiles(&failingWriter{}, connection, "", nil) +} + +func TestZipErrors(t *testing.T) { + user := dataprovider.User{ + HomeDir: filepath.Clean(os.TempDir()), + } + user.Permissions = make(map[string][]string) + user.Permissions["/"] = []string{dataprovider.PermAny} + connection := &Connection{ + BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, user), + request: nil, + } + + testDir := filepath.Join(os.TempDir(), "testDir") + err := os.MkdirAll(testDir, os.ModePerm) + assert.NoError(t, err) + + wr := zip.NewWriter(&failingWriter{}) + err = wr.Close() + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "write error") + } + + err = addZipEntry(wr, connection, "/"+filepath.Base(testDir), "/") + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "write error") + } + + testFilePath := filepath.Join(testDir, "ziptest.zip") + err = os.WriteFile(testFilePath, utils.GenerateRandomBytes(65535), os.ModePerm) + assert.NoError(t, err) + err = addZipEntry(wr, connection, path.Join("/", filepath.Base(testDir), filepath.Base(testFilePath)), + "/"+filepath.Base(testDir)) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "write error") + } + + connection.User.Permissions["/"] = []string{dataprovider.PermListItems} + err = addZipEntry(wr, connection, path.Join("/", filepath.Base(testDir), filepath.Base(testFilePath)), + "/"+filepath.Base(testDir)) + assert.ErrorIs(t, err, os.ErrPermission) + + // creating a virtual folder to a missing path stat is ok but readdir fails + user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped"), + }, + VirtualPath: "/vpath", + }) + connection.User = user + wr = zip.NewWriter(bytes.NewBuffer(make([]byte, 0))) + err = addZipEntry(wr, connection, user.VirtualFolders[0].VirtualPath, "/") + assert.Error(t, err) + + user.Filters.FilePatterns = append(user.Filters.FilePatterns, dataprovider.PatternsFilter{ + Path: "/", + DeniedPatterns: []string{"*.zip"}, + }) + err = addZipEntry(wr, connection, "/"+filepath.Base(testDir), "/") + assert.ErrorIs(t, err, os.ErrPermission) + + err = os.RemoveAll(testDir) + assert.NoError(t, err) +} + func TestWebAdminRedirect(t *testing.T) { b := Binding{ Address: "", @@ -1312,6 +1439,8 @@ func TestHTTPDFile(t *testing.T) { assert.Error(t, err) err = httpdFile.Close() assert.ErrorIs(t, err, common.ErrTransferClosed) + err = os.Remove(p) + assert.NoError(t, err) } func TestChangeUserPwd(t *testing.T) { @@ -1359,6 +1488,13 @@ func TestGetFilesInvalidClaims(t *testing.T) { handleClientGetDirContents(rr, req) assert.Equal(t, http.StatusForbidden, rr.Code) assert.Contains(t, rr.Body.String(), "invalid token claims") + + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath, nil) + req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"])) + handleWebClientDownload(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") } func TestManageKeysInvalidClaims(t *testing.T) { diff --git a/httpd/middleware.go b/httpd/middleware.go index 3c1ebbe6..6cb11516 100644 --- a/httpd/middleware.go +++ b/httpd/middleware.go @@ -3,7 +3,9 @@ package httpd import ( "errors" "net/http" + "runtime/debug" + "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/jwtauth/v5" "github.com/lestrrat-go/jwx/jwt" @@ -177,3 +179,28 @@ func verifyCSRFHeader(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +func recoverer(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rvr := recover(); rvr != nil { + if rvr == http.ErrAbortHandler { + panic(rvr) + } + + logEntry := middleware.GetLogEntry(r) + if logEntry != nil { + logEntry.Panic(rvr, debug.Stack()) + } else { + middleware.PrintPrettyStack(rvr) + } + + w.WriteHeader(http.StatusInternalServerError) + } + }() + + next.ServeHTTP(w, r) + } + + return http.HandlerFunc(fn) +} diff --git a/httpd/server.go b/httpd/server.go index f03b1ef7..c37abf05 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -462,7 +462,7 @@ func (s *httpdServer) initializeRouter() { s.router.Use(middleware.RequestID) s.router.Use(logger.NewStructuredLogger(logger.GetLogger())) - s.router.Use(middleware.Recoverer) + s.router.Use(recoverer) s.router.Use(s.checkConnection) s.router.Use(middleware.GetHead) s.router.Use(middleware.StripSlashes) @@ -574,6 +574,7 @@ func (s *httpdServer) initializeRouter() { router.Get(webClientLogoutPath, handleWebClientLogout) router.With(s.refreshCookie).Get(webClientFilesPath, handleClientGetFiles) router.With(compressor.Handler, s.refreshCookie).Get(webClientDirContentsPath, handleClientGetDirContents) + router.With(s.refreshCookie).Get(webClientDownloadPath, handleWebClientDownload) router.With(s.refreshCookie).Get(webClientCredentialsPath, handleClientGetCredentials) router.Post(webChangeClientPwdPath, handleWebClientChangePwdPost) router.With(checkClientPerm(dataprovider.WebClientPubKeyChangeDisabled)). diff --git a/httpd/webclient.go b/httpd/webclient.go index e6093b2b..2e18a415 100644 --- a/httpd/webclient.go +++ b/httpd/webclient.go @@ -1,6 +1,7 @@ package httpd import ( + "encoding/json" "errors" "fmt" "html/template" @@ -79,11 +80,12 @@ type dirMapping struct { type filesPage struct { baseClientPage - CurrentDir string - ReadDirURL string - Files []os.FileInfo - Error string - Paths []dirMapping + CurrentDir string + ReadDirURL string + DownloadURL string + Files []os.FileInfo + Error string + Paths []dirMapping } type clientMessagePage struct { @@ -219,6 +221,7 @@ func renderFilesPage(w http.ResponseWriter, r *http.Request, files []os.FileInfo Files: files, Error: error, CurrentDir: url.QueryEscape(dirName), + DownloadURL: webClientDownloadPath, ReadDirURL: webClientDirContentsPath, } paths := []dirMapping{} @@ -269,6 +272,43 @@ func handleWebClientLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, webClientLoginPath, http.StatusFound) } +func handleWebClientDownload(w http.ResponseWriter, r *http.Request) { + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + renderClientMessagePage(w, r, "Invalid token claims", "", http.StatusForbidden, nil, "") + return + } + + user, err := dataprovider.UserExists(claims.Username) + if err != nil { + renderClientMessagePage(w, r, "Unable to retrieve your user", "", http.StatusInternalServerError, nil, "") + return + } + + connection := &Connection{ + BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, user), + request: r, + } + common.Connections.Add(connection) + defer common.Connections.Remove(connection.GetID()) + + name := "/" + if _, ok := r.URL.Query()["path"]; ok { + name = utils.CleanPath(r.URL.Query().Get("path")) + } + + files := r.URL.Query().Get("files") + var filesList []string + err = json.Unmarshal([]byte(files), &filesList) + if err != nil { + renderClientMessagePage(w, r, "Unable to get files list", "", http.StatusInternalServerError, err, "") + return + } + + w.Header().Set("Content-Disposition", "attachment; filename=\"sftpgo-download.zip\"") + renderCompressedFiles(w, connection, name, filesList) +} + func handleClientGetDirContents(w http.ResponseWriter, r *http.Request) { claims, err := getTokenClaims(r) if err != nil || claims.Username == "" { diff --git a/sftpd/ssh_cmd.go b/sftpd/ssh_cmd.go index b0322357..6cfe4beb 100644 --- a/sftpd/ssh_cmd.go +++ b/sftpd/ssh_cmd.go @@ -174,7 +174,11 @@ func (c *sshCommand) handleSFTPGoCopy() error { return c.sendErrorResponse(err) } c.connection.Log(logger.LevelDebug, "start copy %#v -> %#v", fsSourcePath, fsDestPath) - err = fscopy.Copy(fsSourcePath, fsDestPath) + err = fscopy.Copy(fsSourcePath, fsDestPath, fscopy.Options{ + OnSymlink: func(src string) fscopy.SymlinkAction { + return fscopy.Skip + }, + }) if err != nil { return c.sendErrorResponse(c.connection.GetFsError(fsSrc, err)) } diff --git a/static/vendor/datatables/dataTables.checkboxes.css b/static/vendor/datatables/dataTables.checkboxes.css new file mode 100644 index 00000000..12ad5c81 --- /dev/null +++ b/static/vendor/datatables/dataTables.checkboxes.css @@ -0,0 +1,1330 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jquery-datatables-checkboxes/dataTables.checkboxes.css at master · gyrocode/jquery-datatables-checkboxes · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Skip to content + + + + + + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + +
+
+ + + + +
+ + + + Permalink + + + +
+ +
+
+ + + master + + + + +
+
+
+ Switch branches/tags + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ +
+ +
+ + + + Go to file + + +
+ + + + + + + + + +
+
+
+ + + +
+ +
+
+
 
+
+ +
+
 
+ Cannot retrieve contributors at this time +
+
+ + + + + + + + + +
+ +
+ + +
+ + 23 lines (20 sloc) + + 568 Bytes +
+ + + +
+ +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
table.dataTable.dt-checkboxes-select tbody tr,
table.dataTable thead th.dt-checkboxes-select-all,
table.dataTable tbody td.dt-checkboxes-cell {
cursor: pointer;
}
+
table.dataTable thead th.dt-checkboxes-select-all,
table.dataTable tbody td.dt-checkboxes-cell {
text-align: center;
}
+
div.dataTables_wrapper span.select-info,
div.dataTables_wrapper span.select-item {
margin-left: 0.5em;
}
+
@media screen and (max-width: 640px) {
div.dataTables_wrapper span.select-info,
div.dataTables_wrapper span.select-item {
margin-left: 0;
display: block;
}
}
+ + + +
+ +
+ + + + +
+ + +
+ + +
+
+ + +
+ + + +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/static/vendor/datatables/dataTables.checkboxes.min.js b/static/vendor/datatables/dataTables.checkboxes.min.js new file mode 100644 index 00000000..f1e2b4eb --- /dev/null +++ b/static/vendor/datatables/dataTables.checkboxes.min.js @@ -0,0 +1,3 @@ +/*! jQuery DataTables Checkboxes v1.2.13-dev - www.gyrocode.com/projects/jquery-datatables-checkboxes/ - License: MIT - Author: Gyrocode LLC / www.gyrocode.com */ +!function(c){"function"==typeof define&&define.amd?define(["jquery","datatables.net"],function(e){return c(e,window,document)}):"object"==typeof exports?module.exports=function(e,t){return e=e||window,t&&t.fn.dataTable||(t=require("datatables.net")(e,t).$),c(t,0,e.document)}:c(jQuery,window,document)}(function(k,e,b){"use strict";function x(e){if(!p.versionCheck||!p.versionCheck("1.10.8"))throw"DataTables Checkboxes requires DataTables 1.10.8 or newer";this.s={dt:new p.Api(e),columns:[],data:{},dataDisabled:{},ignoreSelect:!1},this.s.ctx=this.s.dt.settings()[0],this.s.ctx.checkboxes||(e.checkboxes=this)._constructor()}var p=k.fn.dataTable;x.prototype={_constructor:function(){for(var e,t,c,s,o,l,a,n=this,d=n.s.dt,i=n.s.ctx,h=!1,r=!1,u=0;u'}),p.ext.internal._fnColumnOptions(i,u,t),e.removeClass("sorting"),e.off(".dt"),null===i.sAjaxSource&&((c=d.cells("tr",u)).invalidate("data"),k(c.nodes()).addClass(t.className)),n.s.data[u]={},n.s.dataDisabled[u]={},n.s.columns.push(u),i.aoColumns[u].checkboxes.selectRow&&(i._select?r=!0:i.aoColumns[u].checkboxes.selectRow=!1),i.aoColumns[u].checkboxes.selectAll&&(e.data("html",e.html()),null!==i.aoColumns[u].checkboxes.selectAllRender&&(s="",k.isFunction(i.aoColumns[u].checkboxes.selectAllRender)?s=i.aoColumns[u].checkboxes.selectAllRender():"string"==typeof i.aoColumns[u].checkboxes.selectAllRender&&(s=i.aoColumns[u].checkboxes.selectAllRender),e.html(s).addClass("dt-checkboxes-select-all").attr("data-col",u))))}h&&(n.loadState(),o=k(d.table().node()),l=k(d.table().body()),a=k(d.table().container()),r&&(o.addClass("dt-checkboxes-select"),o.on("user-select.dt.dtCheckboxes",function(e,t,c,s,o){n.onDataTablesUserSelect(e,t,c,s,o)}),o.on("select.dt.dtCheckboxes deselect.dt.dtCheckboxes",function(e,t,c,s){n.onDataTablesSelectDeselect(e,c,s)}),i._select.info&&(d.select.info(!1),o.on("draw.dt.dtCheckboxes select.dt.dtCheckboxes deselect.dt.dtCheckboxes",function(){n.showInfoSelected()}))),o.on("draw.dt.dtCheckboxes",function(e){n.onDataTablesDraw(e)}),l.on("click.dtCheckboxes","input.dt-checkboxes",function(e){n.onClick(e,this)}),a.on("click.dtCheckboxes",'thead th.dt-checkboxes-select-all input[type="checkbox"]',function(e){n.onClickSelectAll(e,this)}),a.on("click.dtCheckboxes","thead th.dt-checkboxes-select-all",function(){k('input[type="checkbox"]',this).not(":disabled").trigger("click")}),r||a.on("click.dtCheckboxes","tbody td.dt-checkboxes-cell",function(){k('input[type="checkbox"]',this).not(":disabled").trigger("click")}),a.on("click.dtCheckboxes","thead th.dt-checkboxes-select-all label, tbody td.dt-checkboxes-cell label",function(e){e.preventDefault()}),k(b).on("click.dtCheckboxes",'.fixedHeader-floating thead th.dt-checkboxes-select-all input[type="checkbox"]',function(e){i._fixedHeader&&i._fixedHeader.dom.header.floating&&n.onClickSelectAll(e,this)}),k(b).on("click.dtCheckboxes",".fixedHeader-floating thead th.dt-checkboxes-select-all",function(){i._fixedHeader&&i._fixedHeader.dom.header.floating&&k('input[type="checkbox"]',this).trigger("click")}),o.on("init.dt.dtCheckboxes",function(){setTimeout(function(){n.onDataTablesInit()},0)}),o.on("stateSaveParams.dt.dtCheckboxes",function(e,t,c){n.onDataTablesStateSave(e,t,c)}),o.one("destroy.dt.dtCheckboxes",function(e,t){n.onDataTablesDestroy(e,t)}))},onDataTablesInit:function(){var o=this,e=o.s.dt,t=o.s.ctx;t.oFeatures.bServerSide||(t.oFeatures.bStateSave&&o.updateState(),k(e.table().node()).on("xhr.dt.dtCheckboxes",function(e,t,c,s){o.onDataTablesXhr(e.settings,c,s)}))},onDataTablesUserSelect:function(e,t,c,s){var o=s.index().row,l=this.getSelectRowColIndex(),a=t.cell({row:o,column:l}).data();this.isCellSelectable(l,a)||e.preventDefault()},onDataTablesSelectDeselect:function(e,t,c){var s,o,l=this,a=l.s.dt;l.s.ignoreSelect||"row"!==t||null!==(s=l.getSelectRowColIndex())&&(o=a.cells(c,s),l.updateData(o,s,"select"===e.type),l.updateCheckbox(o,s,"select"===e.type),l.updateSelectAll(s))},onDataTablesStateSave:function(e,t,c){var s=this,o=s.s.ctx;k.each(s.s.columns,function(e,t){o.aoColumns[t].checkboxes.stateSave&&(Object.prototype.hasOwnProperty.call(c,"checkboxes")||(c.checkboxes=[]),c.checkboxes[t]=s.s.data[t])})},onDataTablesDestroy:function(){var e=this.s.dt,t=k(e.table().node()),c=k(e.table().body()),s=k(e.table().container());k(b).off("click.dtCheckboxes"),s.off(".dtCheckboxes"),c.off(".dtCheckboxes"),t.off(".dtCheckboxes"),this.s.data={},this.s.dataDisabled={},k(".dt-checkboxes-select-all",t).each(function(e,t){k(t).html(k(t).data("html")).removeClass("dt-checkboxes-select-all")})},onDataTablesDraw:function(){var c=this,e=c.s.ctx;(e.oFeatures.bServerSide||e.oFeatures.bDeferRender)&&c.updateStateCheckboxes({page:"current",search:"none"}),k.each(c.s.columns,function(e,t){c.updateSelectAll(t)})},onDataTablesXhr:function(){var c=this,e=c.s.dt,t=c.s.ctx,s=k(e.table().node());k.each(c.s.columns,function(e,t){c.s.data[t]={},c.s.dataDisabled[t]={}}),t.oFeatures.bStateSave&&(c.loadState(),s.one("draw.dt.dtCheckboxes",function(){c.updateState()}))},updateData:function(e,t,c){var s=this.s.dt,o=this.s.ctx;o.aoColumns[t].checkboxes&&(e.data().each(function(e){c?o.checkboxes.s.data[t][e]=1:delete o.checkboxes.s.data[t][e]}),o.oFeatures.bStateSave&&o.aoColumns[t].checkboxes.stateSave&&s.state.save())},updateSelect:function(e,t){var c=this.s.dt;this.s.ctx._select&&(this.s.ignoreSelect=!0,t?c.rows(e).select():c.rows(e).deselect(),this.s.ignoreSelect=!1)},updateCheckbox:function(e,t,c){var s=this.s.ctx,o=e.nodes();o.length&&(k("input.dt-checkboxes",o).not(":disabled").prop("checked",c),k.isFunction(s.aoColumns[t].checkboxes.selectCallback)&&s.aoColumns[t].checkboxes.selectCallback(o,c))},updateState:function(){var c=this,e=(c.s.dt,c.s.ctx);c.updateStateCheckboxes({page:"all",search:"none"}),e._oFixedColumns&&setTimeout(function(){k.each(c.s.columns,function(e,t){c.updateSelectAll(t)})},0)},updateStateCheckboxes:function(e){var o=this,t=o.s.dt,l=o.s.ctx;t.cells("tr",o.s.columns,e).every(function(e,t){var c=this.data(),s=o.isCellSelectable(t,c);Object.prototype.hasOwnProperty.call(l.checkboxes.s.data,t)&&Object.prototype.hasOwnProperty.call(l.checkboxes.s.data[t],c)&&(l.aoColumns[t].checkboxes.selectRow&&s&&o.updateSelect(e,!0),o.updateCheckbox(this,t,!0)),s||k("input.dt-checkboxes",this.node()).prop("disabled",!0)})},onClick:function(e,c){var s=this,t=s.s.dt,o=s.s.ctx,l=k(c).closest("td"),a=l.parents(".DTFC_Cloned").length?t.fixedColumns().cellIndex(l):l,n=t.cell(a),d=n.index().column;o.aoColumns[d].checkboxes.selectRow?setTimeout(function(){var e=n.data(),t=Object.prototype.hasOwnProperty.call(s.s.data,d)&&Object.prototype.hasOwnProperty.call(s.s.data[d],e);t!==c.checked&&(s.updateCheckbox(n,d,t),s.updateSelectAll(d))},0):(n.checkboxes.select(c.checked),e.stopPropagation())},onClickSelectAll:function(e,t){var c=this.s.dt,s=this.s.ctx,o=null,l=k(t).closest("th");o=l.parents(".DTFC_Cloned").length?c.fixedColumns().cellIndex(l).column:c.column(l).index(),k(t).data("is-changed",!0),c.column(o,{page:s.aoColumns[o].checkboxes&&s.aoColumns[o].checkboxes.selectAllPages?"all":"current",search:"applied"}).checkboxes.select(t.checked),e.stopPropagation()},loadState:function(){var c,s=this,e=s.s.dt,o=s.s.ctx;o.oFeatures.bStateSave&&(c=e.state.loaded(),k.each(s.s.columns,function(e,t){c&&c.checkboxes&&c.checkboxes.hasOwnProperty(t)&&o.aoColumns[t].checkboxes.stateSave&&(s.s.data[t]=c.checkboxes[t])}))},updateSelectAll:function(c){var e,t,s,o,l,a,n,d,i,h,r,u=this,b=u.s.dt,x=u.s.ctx;x.aoColumns[c].checkboxes&&x.aoColumns[c].checkboxes.selectAll&&(e=b.cells("tr",c,{page:x.aoColumns[c].checkboxes.selectAllPages?"all":"current",search:"applied"}),t=b.table().container(),s=k('.dt-checkboxes-select-all[data-col="'+c+'"] input[type="checkbox"]',t),l=o=0,a=e.data(),k.each(a,function(e,t){u.isCellSelectable(c,t)?Object.prototype.hasOwnProperty.call(u.s.data,c)&&Object.prototype.hasOwnProperty.call(u.s.data[c],t)&&o++:l++}),x._fixedHeader&&x._fixedHeader.dom.header.floating&&(s=k('.fixedHeader-floating .dt-checkboxes-select-all[data-col="'+c+'"] input[type="checkbox"]')),d=0===o?n=!1:o+l===a.length?!(n=!0):n=!0,i=s.data("is-changed"),h=s.prop("checked"),r=s.prop("indeterminate"),!i&&h===n&&r===d||(s.data("is-changed",!1),s.prop({checked:!d&&n,indeterminate:d}),k.isFunction(x.aoColumns[c].checkboxes.selectAllCallback)&&x.aoColumns[c].checkboxes.selectAllCallback(s.closest("th").get(0),n,d)))},showInfoSelected:function(){var n=this.s.dt,e=this.s.ctx;if(e.aanFeatures.i){var t=this.getSelectRowColIndex();if(null!==t){var d=0;for(var c in e.checkboxes.s.data[t])Object.prototype.hasOwnProperty.call(e.checkboxes.s.data,t)&&Object.prototype.hasOwnProperty.call(e.checkboxes.s.data[t],c)&&d++;k.each(e.aanFeatures.i,function(e,t){var c,s,o=k(t),l=k('');c="row",s=d,l.append(k('').append(n.i18n("select."+c+"s",{_:"%d "+c+"s selected",0:"",1:"1 "+c+" selected"},s)));var a=o.children("span.select-info");a.length&&a.remove(),""!==l.text()&&o.append(l)})}}},isCellSelectable:function(e,t){var c=this.s.ctx;return!Object.prototype.hasOwnProperty.call(c.checkboxes.s.dataDisabled,e)||!Object.prototype.hasOwnProperty.call(c.checkboxes.s.dataDisabled[e],t)},getCellIndex:function(e){var t=this.s.dt;return this.s.ctx._oFixedColumns?t.fixedColumns().cellIndex(e):t.cell(e).index()},getSelectRowColIndex:function(){for(var e=this.s.ctx,t=null,c=0;c'};var t=k.fn.dataTable.Api;return t.register("checkboxes()",function(){return this}),t.registerPlural("columns().checkboxes.select()","column().checkboxes.select()",function(i){return void 0===i&&(i=!0),this.iterator("column-rows",function(c,s,e,t,o){var l,a,n,d;c.aoColumns[s].checkboxes&&(d=[],k.each(o,function(e,t){d.push({row:t,column:s})}),a=(l=this.cells(d)).data(),n=[],d=[],k.each(a,function(e,t){c.checkboxes.isCellSelectable(s,t)&&(d.push({row:o[e],column:s}),n.push(o[e]))}),l=this.cells(d),c.checkboxes.updateData(l,s,i),c.aoColumns[s].checkboxes.selectRow&&c.checkboxes.updateSelect(n,i),c.checkboxes.updateCheckbox(l,s,i),c.checkboxes.updateSelectAll(s),c.checkboxes.updateFixedColumn(s))},1)}),t.registerPlural("cells().checkboxes.select()","cell().checkboxes.select()",function(l){return void 0===l&&(l=!0),this.iterator("cell",function(e,t,c){var s,o;e.aoColumns[c].checkboxes&&(s=this.cells([{row:t,column:c}]),o=this.cell({row:t,column:c}).data(),e.checkboxes.isCellSelectable(c,o)&&(e.checkboxes.updateData(s,c,l),e.aoColumns[c].checkboxes.selectRow&&e.checkboxes.updateSelect(t,l),e.checkboxes.updateCheckbox(s,c,l),e.checkboxes.updateSelectAll(c),e.checkboxes.updateFixedColumn(c)))},1)}),t.registerPlural("cells().checkboxes.enable()","cell().checkboxes.enable()",function(a){return void 0===a&&(a=!0),this.iterator("cell",function(e,t,c){var s,o,l;e.aoColumns[c].checkboxes&&(o=(s=this.cell({row:t,column:c})).data(),a?delete e.checkboxes.s.dataDisabled[c][o]:e.checkboxes.s.dataDisabled[c][o]=1,(l=s.node())&&k("input.dt-checkboxes",l).prop("disabled",!a),e.aoColumns[c].checkboxes.selectRow&&Object.prototype.hasOwnProperty.call(e.checkboxes.s.data,c)&&Object.prototype.hasOwnProperty.call(e.checkboxes.s.data[c],o)&&e.checkboxes.updateSelect(t,a))},1)}),t.registerPlural("cells().checkboxes.disable()","cell().checkboxes.disable()",function(e){return void 0===e&&(e=!0),this.checkboxes.enable(!e)}),t.registerPlural("columns().checkboxes.deselect()","column().checkboxes.deselect()",function(e){return void 0===e&&(e=!0),this.checkboxes.select(!e)}),t.registerPlural("cells().checkboxes.deselect()","cell().checkboxes.deselect()",function(e){return void 0===e&&(e=!0),this.checkboxes.select(!e)}),t.registerPlural("columns().checkboxes.deselectAll()","column().checkboxes.deselectAll()",function(){return this.iterator("column",function(e,t){e.aoColumns[t].checkboxes&&(e.checkboxes.s.data[t]={},this.column(t).checkboxes.select(!1))},1)}),t.registerPlural("columns().checkboxes.selected()","column().checkboxes.selected()",function(){return this.iterator("column-rows",function(c,s,e,t,o){if(c.aoColumns[s].checkboxes){var l,a,n=[];return c.oFeatures.bServerSide?k.each(c.checkboxes.s.data[s],function(e){c.checkboxes.isCellSelectable(s,e)&&n.push(e)}):(l=[],k.each(o,function(e,t){l.push({row:t,column:s})}),a=this.cells(l).data(),k.each(a,function(e,t){Object.prototype.hasOwnProperty.call(c.checkboxes.s.data,s)&&Object.prototype.hasOwnProperty.call(c.checkboxes.s.data[s],t)&&c.checkboxes.isCellSelectable(s,t)&&n.push(t)})),n}return[]},1)}),x.version="1.2.13-dev",k.fn.DataTable.Checkboxes=x,k.fn.dataTable.Checkboxes=x,k(b).on("preInit.dt.dtCheckboxes",function(e,t){"dt"===e.namespace&&new x(t)}),x}); +//# sourceMappingURL=dataTables.checkboxes.min.js.map \ No newline at end of file diff --git a/templates/webclient/files.html b/templates/webclient/files.html index d0dd8b17..bb076cd3 100644 --- a/templates/webclient/files.html +++ b/templates/webclient/files.html @@ -7,7 +7,13 @@ - + + {{end}} {{define "page_body"}} @@ -29,6 +35,7 @@ + @@ -49,97 +56,108 @@ - + diff --git a/utils/utils.go b/utils/utils.go index 8f8e1abb..398d3762 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -40,8 +40,10 @@ const ( ) var ( - xForwardedFor = http.CanonicalHeaderKey("X-Forwarded-For") - xRealIP = http.CanonicalHeaderKey("X-Real-IP") + xForwardedFor = http.CanonicalHeaderKey("X-Forwarded-For") + xRealIP = http.CanonicalHeaderKey("X-Real-IP") + cfConnectingIP = http.CanonicalHeaderKey("CF-Connecting-IP") + trueClientIP = http.CanonicalHeaderKey("True-Client-IP") ) // IsStringInSlice searches a string in a slice and returns true if the string is found @@ -530,6 +532,10 @@ func GetRealIP(r *http.Request) string { if xrip := r.Header.Get(xRealIP); xrip != "" { ip = xrip + } else if clientIP := r.Header.Get(trueClientIP); clientIP != "" { + ip = clientIP + } else if clientIP := r.Header.Get(cfConnectingIP); clientIP != "" { + ip = clientIP } else if xff := r.Header.Get(xForwardedFor); xff != "" { i := strings.Index(xff, ", ") if i == -1 { diff --git a/vfs/osfs.go b/vfs/osfs.go index a8414214..aad47cc9 100644 --- a/vfs/osfs.go +++ b/vfs/osfs.go @@ -104,7 +104,11 @@ func (fs *OsFs) Rename(source, target string) error { if err != nil && isCrossDeviceError(err) { fsLog(fs, logger.LevelWarn, "cross device error detected while renaming %#v -> %#v. Trying a copy and remove, this could take a long time", source, target) - err = fscopy.Copy(source, target) + err = fscopy.Copy(source, target, fscopy.Options{ + OnSymlink: func(src string) fscopy.SymlinkAction { + return fscopy.Skip + }, + }) if err != nil { fsLog(fs, logger.LevelDebug, "cross device copy error: %v", err) return err diff --git a/webdavd/internal_test.go b/webdavd/internal_test.go index 1b1cedad..443007cb 100644 --- a/webdavd/internal_test.go +++ b/webdavd/internal_test.go @@ -432,10 +432,18 @@ func TestRemoteAddress(t *testing.T) { assert.NoError(t, err) assert.Empty(t, req.RemoteAddr) - req.Header.Set("X-Forwarded-For", remoteAddr1) + req.Header.Set("True-Client-IP", remoteAddr1) ip := utils.GetRealIP(req) assert.Equal(t, remoteAddr1, ip) - // this will be ignore, remoteAddr1 is not allowed to se this header + req.Header.Del("True-Client-IP") + req.Header.Set("CF-Connecting-IP", remoteAddr1) + ip = utils.GetRealIP(req) + assert.Equal(t, remoteAddr1, ip) + req.Header.Del("CF-Connecting-IP") + req.Header.Set("X-Forwarded-For", remoteAddr1) + ip = utils.GetRealIP(req) + assert.Equal(t, remoteAddr1, ip) + // this will be ignored, remoteAddr1 is not allowed to se this header req.Header.Set("X-Forwarded-For", remoteAddr2) req.RemoteAddr = remoteAddr1 ip = server.checkRemoteAddress(req)
Type Name Size