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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ message }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Permalink
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Cannot retrieve contributors at this time
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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;
+
+
+
+ }
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You can’t perform that action at this time.
+
+
+
+
+
+
+
You signed in with another tab or window. Reload to refresh your session.
+
You signed out in another tab or window. Reload to refresh your session.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
Type
Name
Size
@@ -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)