From 23d9ebfc91ae9414a5672ab9e7dab47766b320b2 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Thu, 6 May 2021 21:35:43 +0200 Subject: [PATCH] add a basic front-end web interface for end-users Fixes #339 #321 #398 --- README.md | 1 + common/common.go | 2 +- common/connection.go | 4 +- common/connection_test.go | 2 +- config/config.go | 11 +- config/config_test.go | 7 + dataprovider/bolt.go | 6 +- dataprovider/dataprovider.go | 7 +- dataprovider/memory.go | 6 +- dataprovider/sqlcommon.go | 6 +- dataprovider/user.go | 31 + docs/check-password-hook.md | 2 +- docs/dynamic-user-mod.md | 2 +- docs/external-auth.md | 4 +- docs/full-configuration.md | 5 +- docs/howto/postgresql-s3.md | 2 +- docs/post-login-hook.md | 2 +- docs/web-admin.md | 2 +- docs/web-client.md | 10 + ftpd/cryptfs_test.go | 51 + ftpd/server.go | 4 +- go.mod | 23 +- go.sum | 43 +- httpd/auth_utils.go | 32 +- httpd/file.go | 87 ++ httpd/handler.go | 104 ++ httpd/httpd.go | 230 +-- httpd/httpd_test.go | 858 ++++++++++- httpd/internal_test.go | 455 +++++- httpd/middleware.go | 130 +- httpd/schema/openapi.yaml | 16 +- httpd/server.go | 196 ++- httpd/web.go | 1565 +-------------------- httpd/webadmin.go | 1559 ++++++++++++++++++++ httpd/webclient.go | 656 +++++++++ httpdtest/httpdtest.go | 8 + pkgs/build.sh | 2 +- sftpgo.json | 3 +- templates/{ => webadmin}/admin.html | 0 templates/{ => webadmin}/admins.html | 9 +- templates/{ => webadmin}/base.html | 0 templates/{ => webadmin}/changepwd.html | 0 templates/{ => webadmin}/connections.html | 12 +- templates/{ => webadmin}/folder.html | 0 templates/{ => webadmin}/folders.html | 7 +- templates/{ => webadmin}/fsconfig.html | 0 templates/{ => webadmin}/login.html | 0 templates/{ => webadmin}/maintenance.html | 0 templates/{ => webadmin}/message.html | 0 templates/{ => webadmin}/status.html | 0 templates/{ => webadmin}/user.html | 12 + templates/{ => webadmin}/users.html | 7 +- templates/webclient/base.html | 217 +++ templates/webclient/credentials.html | 74 + templates/webclient/files.html | 125 ++ templates/webclient/login.html | 134 ++ templates/webclient/message.html | 59 + utils/utils.go | 8 +- vfs/cryptfs.go | 1 + vfs/osfs.go | 10 + webdavd/file.go | 1 + webdavd/server.go | 4 +- webdavd/webdavd_test.go | 2 +- windows-installer/sftpgo.iss | 3 +- 64 files changed, 4961 insertions(+), 1858 deletions(-) create mode 100644 docs/web-client.md create mode 100644 httpd/file.go create mode 100644 httpd/handler.go create mode 100644 httpd/webadmin.go create mode 100644 httpd/webclient.go rename templates/{ => webadmin}/admin.html (100%) rename templates/{ => webadmin}/admins.html (96%) rename templates/{ => webadmin}/base.html (100%) rename templates/{ => webadmin}/changepwd.html (100%) rename templates/{ => webadmin}/connections.html (95%) rename templates/{ => webadmin}/folder.html (100%) rename templates/{ => webadmin}/folders.html (98%) rename templates/{ => webadmin}/fsconfig.html (100%) rename templates/{ => webadmin}/login.html (100%) rename templates/{ => webadmin}/maintenance.html (100%) rename templates/{ => webadmin}/message.html (100%) rename templates/{ => webadmin}/status.html (100%) rename templates/{ => webadmin}/user.html (97%) rename templates/{ => webadmin}/users.html (98%) create mode 100644 templates/webclient/base.html create mode 100644 templates/webclient/credentials.html create mode 100644 templates/webclient/files.html create mode 100644 templates/webclient/login.html create mode 100644 templates/webclient/message.html diff --git a/README.md b/README.md index 70910fb7..6c0913e4 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Several storage backends are supported: local filesystem, encrypted local filesy - Support for HAProxy PROXY protocol: you can proxy and/or load balance the SFTP/SCP/FTP/WebDAV service without losing the information about the client's address. - [REST API](./docs/rest-api.md) for users and folders management, backup, restore and real time reports of the active connections with possibility of forcibly closing a connection. - [Web based administration interface](./docs/web-admin.md) to easily manage users, folders and connections. +- [Web client interface](./docs/web-client.md) so that end users can change their credentials and browse their files. - Easy [migration](./examples/convertusers) from Linux system user accounts. - [Portable mode](./docs/portable-mode.md): a convenient way to share a single directory on demand. - [SFTP subsystem mode](./docs/sftp-subsystem.md): you can use SFTPGo as OpenSSH's SFTP subsystem. diff --git a/common/common.go b/common/common.go index e3caf063..45557af5 100644 --- a/common/common.go +++ b/common/common.go @@ -104,7 +104,7 @@ var ( QuotaScans ActiveScans idleTimeoutTicker *time.Ticker idleTimeoutTickerDone chan bool - supportedProtocols = []string{ProtocolSFTP, ProtocolSCP, ProtocolSSH, ProtocolFTP, ProtocolWebDAV} + supportedProtocols = []string{ProtocolSFTP, ProtocolSCP, ProtocolSSH, ProtocolFTP, ProtocolWebDAV, ProtocolHTTP} // the map key is the protocol, for each protocol we can have multiple rate limiters rateLimiters map[string][]*rateLimiter ) diff --git a/common/connection.go b/common/connection.go index cfb6159a..5a83b954 100644 --- a/common/connection.go +++ b/common/connection.go @@ -1029,7 +1029,7 @@ func (c *BaseConnection) GetPermissionDeniedError() error { switch c.protocol { case ProtocolSFTP: return sftp.ErrSSHFxPermissionDenied - case ProtocolWebDAV, ProtocolFTP: + case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP: return os.ErrPermission default: return ErrPermissionDenied @@ -1041,7 +1041,7 @@ func (c *BaseConnection) GetNotExistError() error { switch c.protocol { case ProtocolSFTP: return sftp.ErrSSHFxNoSuchFile - case ProtocolWebDAV, ProtocolFTP: + case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP: return os.ErrNotExist default: return ErrNotExist diff --git a/common/connection_test.go b/common/connection_test.go index e46a02e4..422bb72d 100644 --- a/common/connection_test.go +++ b/common/connection_test.go @@ -224,7 +224,7 @@ func TestErrorsMapping(t *testing.T) { err := conn.GetFsError(fs, os.ErrNotExist) if protocol == ProtocolSFTP { assert.EqualError(t, err, sftp.ErrSSHFxNoSuchFile.Error()) - } else if protocol == ProtocolWebDAV || protocol == ProtocolFTP { + } else if protocol == ProtocolWebDAV || protocol == ProtocolFTP || protocol == ProtocolHTTP { assert.EqualError(t, err, os.ErrNotExist.Error()) } else { assert.EqualError(t, err, ErrNotExist.Error()) diff --git a/config/config.go b/config/config.go index cc9fcd3f..33bac3e2 100644 --- a/config/config.go +++ b/config/config.go @@ -65,6 +65,7 @@ var ( Address: "127.0.0.1", Port: 8080, EnableWebAdmin: true, + EnableWebClient: true, EnableHTTPS: false, ClientAuthType: 0, TLSCipherSuites: nil, @@ -236,7 +237,7 @@ func Init() { TemplatesPath: "templates", StaticFilesPath: "static", BackupsPath: "backups", - WebAdminRoot: "", + WebRoot: "", CertificateFile: "", CertificateKeyFile: "", CACertificates: nil, @@ -807,6 +808,12 @@ func getHTTPDBindingFromEnv(idx int) { isSet = true } + enableWebClient, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__ENABLE_WEB_CLIENT", idx)) + if ok { + binding.EnableWebClient = enableWebClient + isSet = true + } + enableHTTPS, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__ENABLE_HTTPS", idx)) if ok { binding.EnableHTTPS = enableHTTPS @@ -952,7 +959,7 @@ func setViperDefaults() { viper.SetDefault("httpd.templates_path", globalConf.HTTPDConfig.TemplatesPath) viper.SetDefault("httpd.static_files_path", globalConf.HTTPDConfig.StaticFilesPath) viper.SetDefault("httpd.backups_path", globalConf.HTTPDConfig.BackupsPath) - viper.SetDefault("httpd.web_admin_root", globalConf.HTTPDConfig.WebAdminRoot) + viper.SetDefault("httpd.web_root", globalConf.HTTPDConfig.WebRoot) viper.SetDefault("httpd.certificate_file", globalConf.HTTPDConfig.CertificateFile) viper.SetDefault("httpd.certificate_key_file", globalConf.HTTPDConfig.CertificateKeyFile) viper.SetDefault("httpd.ca_certificates", globalConf.HTTPDConfig.CACertificates) diff --git a/config/config_test.go b/config/config_test.go index 408194e9..27776cc3 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -626,9 +626,11 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { os.Setenv("SFTPGO_HTTPD__BINDINGS__1__PORT", "8000") os.Setenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_HTTPS", "0") os.Setenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_WEB_ADMIN", "1") + os.Setenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_WEB_CLIENT", "1") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ADDRESS", "127.0.1.1") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__PORT", "9000") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_ADMIN", "0") + os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_CLIENT", "0") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_HTTPS", "1") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_AUTH_TYPE", "1") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES", " TLS_AES_256_GCM_SHA384 , TLS_CHACHA20_POLY1305_SHA256") @@ -640,10 +642,12 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__PORT") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_HTTPS") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_WEB_ADMIN") + os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_WEB_CLIENT") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ADDRESS") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__PORT") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_HTTPS") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_ADMIN") + os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_CLIENT") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_AUTH_TYPE") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES") }) @@ -657,18 +661,21 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { require.Equal(t, sockPath, bindings[0].Address) require.False(t, bindings[0].EnableHTTPS) require.True(t, bindings[0].EnableWebAdmin) + require.True(t, bindings[0].EnableWebClient) require.Len(t, bindings[0].TLSCipherSuites, 1) require.Equal(t, "TLS_AES_128_GCM_SHA256", bindings[0].TLSCipherSuites[0]) require.Equal(t, 8000, bindings[1].Port) require.Equal(t, "127.0.0.1", bindings[1].Address) require.False(t, bindings[1].EnableHTTPS) require.True(t, bindings[1].EnableWebAdmin) + require.True(t, bindings[1].EnableWebClient) require.Nil(t, bindings[1].TLSCipherSuites) require.Equal(t, 9000, bindings[2].Port) require.Equal(t, "127.0.1.1", bindings[2].Address) require.True(t, bindings[2].EnableHTTPS) require.False(t, bindings[2].EnableWebAdmin) + require.False(t, bindings[2].EnableWebClient) require.Equal(t, 1, bindings[2].ClientAuthType) require.Len(t, bindings[2].TLSCipherSuites, 2) require.Equal(t, "TLS_AES_256_GCM_SHA384", bindings[2].TLSCipherSuites[0]) diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index 45818640..22600abd 100644 --- a/dataprovider/bolt.go +++ b/dataprovider/bolt.go @@ -111,7 +111,7 @@ func (p *BoltProvider) validateUserAndTLSCert(username, protocol string, tlsCert user, err := p.userExists(username) if err != nil { providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err) - return user, ErrInvalidCredentials + return user, err } return checkUserAndTLSCertificate(&user, protocol, tlsCert) } @@ -124,7 +124,7 @@ func (p *BoltProvider) validateUserAndPass(username, password, ip, protocol stri user, err := p.userExists(username) if err != nil { providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err) - return user, ErrInvalidCredentials + return user, err } return checkUserAndPass(&user, password, ip, protocol) } @@ -147,7 +147,7 @@ func (p *BoltProvider) validateUserAndPubKey(username string, pubKey []byte) (Us user, err := p.userExists(username) if err != nil { providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err) - return user, "", ErrInvalidCredentials + return user, "", err } return checkUserAndPubKey(&user, pubKey) } diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index d1e2992c..befffc45 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -112,7 +112,7 @@ var ( // ErrNoAuthTryed defines the error for connection closed before authentication ErrNoAuthTryed = errors.New("no auth tryed") // ValidProtocols defines all the valid protcols - ValidProtocols = []string{"SSH", "FTP", "DAV"} + ValidProtocols = []string{"SSH", "FTP", "DAV", "HTTP"} // ErrNoInitRequired defines the error returned by InitProvider if no inizialization/update is required ErrNoInitRequired = errors.New("the data provider is up to date") // ErrInvalidCredentials defines the error to return if the supplied credentials are invalid @@ -1387,6 +1387,11 @@ func validateFilters(user *User) error { return &ValidationError{err: fmt.Sprintf("invalid TLS username: %#v", user.Filters.TLSUsername)} } } + for _, opts := range user.Filters.WebClient { + if !utils.IsStringInSlice(opts, WebClientOptions) { + return &ValidationError{err: fmt.Sprintf("invalid web client options %#v", opts)} + } + } return validateFileFilters(user) } diff --git a/dataprovider/memory.go b/dataprovider/memory.go index 65ab5ca6..e611d2f1 100644 --- a/dataprovider/memory.go +++ b/dataprovider/memory.go @@ -96,7 +96,7 @@ func (p *MemoryProvider) validateUserAndTLSCert(username, protocol string, tlsCe user, err := p.userExists(username) if err != nil { providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err) - return user, ErrInvalidCredentials + return user, err } return checkUserAndTLSCertificate(&user, protocol, tlsCert) } @@ -109,7 +109,7 @@ func (p *MemoryProvider) validateUserAndPass(username, password, ip, protocol st user, err := p.userExists(username) if err != nil { providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err) - return user, ErrInvalidCredentials + return user, err } return checkUserAndPass(&user, password, ip, protocol) } @@ -122,7 +122,7 @@ func (p *MemoryProvider) validateUserAndPubKey(username string, pubKey []byte) ( user, err := p.userExists(username) if err != nil { providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err) - return user, "", ErrInvalidCredentials + return user, "", err } return checkUserAndPubKey(&user, pubKey) } diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index 92396482..08b2da54 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -224,7 +224,7 @@ func sqlCommonValidateUserAndPass(username, password, ip, protocol string, dbHan user, err := sqlCommonGetUserByUsername(username, dbHandle) if err != nil { providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err) - return user, ErrInvalidCredentials + return user, err } return checkUserAndPass(&user, password, ip, protocol) } @@ -237,7 +237,7 @@ func sqlCommonValidateUserAndTLSCertificate(username, protocol string, tlsCert * user, err := sqlCommonGetUserByUsername(username, dbHandle) if err != nil { providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err) - return user, ErrInvalidCredentials + return user, err } return checkUserAndTLSCertificate(&user, protocol, tlsCert) } @@ -250,7 +250,7 @@ func sqlCommonValidateUserAndPubKey(username string, pubKey []byte, dbHandle *sq user, err := sqlCommonGetUserByUsername(username, dbHandle) if err != nil { providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err) - return user, "", ErrInvalidCredentials + return user, "", err } return checkUserAndPubKey(&user, pubKey) } diff --git a/dataprovider/user.go b/dataprovider/user.go index fab0bca2..e5155062 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -1,6 +1,8 @@ package dataprovider import ( + "crypto/sha256" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -48,6 +50,16 @@ const ( PermChtimes = "chtimes" ) +// Web Client restrictions +const ( + WebClientPubKeyChangeDisabled = "publickey-change-disabled" +) + +var ( + // WebClientOptions defines the available options for the web client interface + WebClientOptions = []string{WebClientPubKeyChangeDisabled} +) + // Available login methods const ( LoginMethodNoAuthTryed = "no_auth_tryed" @@ -160,6 +172,8 @@ type UserFilters struct { // these checks will speed up login. // You could, for example, disable these checks after the first login DisableFsChecks bool `json:"disable_fs_checks,omitempty"` + // WebClient related configuration options + WebClient []string `json:"web_client,omitempty"` } // User defines a SFTPGo user @@ -839,6 +853,21 @@ func (u *User) isFilePatternAllowed(virtualPath string) bool { return true } +// CanManahePublicKeys return true if this user is allowed to manage public keys +// from the web client +func (u *User) CanManahePublicKeys() bool { + return !utils.IsStringInSlice(WebClientPubKeyChangeDisabled, u.Filters.WebClient) +} + +// GetSignature returns a signature for this admin. +// It could change after an update +func (u *User) GetSignature() string { + data := []byte(fmt.Sprintf("%v_%v_%v", u.Username, u.Status, u.ExpirationDate)) + data = append(data, []byte(u.Password)...) + signature := sha256.Sum256(data) + return base64.StdEncoding.EncodeToString(signature[:]) +} + // IsLoginFromAddrAllowed returns true if the login is allowed from the specified remoteAddr. // If AllowedIP is defined only the specified IP/Mask can login. // If DeniedIP is defined the specified IP/Mask cannot login. @@ -1094,6 +1123,8 @@ func (u *User) getACopy() User { filters.Hooks.PreLoginDisabled = u.Filters.Hooks.PreLoginDisabled filters.Hooks.CheckPasswordDisabled = u.Filters.Hooks.CheckPasswordDisabled filters.DisableFsChecks = u.Filters.DisableFsChecks + filters.WebClient = make([]string, len(u.Filters.WebClient)) + copy(filters.WebClient, u.Filters.WebClient) return User{ ID: u.ID, diff --git a/docs/check-password-hook.md b/docs/check-password-hook.md index 5874bb53..aad8cc10 100644 --- a/docs/check-password-hook.md +++ b/docs/check-password-hook.md @@ -16,7 +16,7 @@ If the hook defines an external program it can read the following environment va - `SFTPGO_AUTHD_USERNAME` - `SFTPGO_AUTHD_PASSWORD` - `SFTPGO_AUTHD_IP` -- `SFTPGO_AUTHD_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV` +- `SFTPGO_AUTHD_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP` Previous global environment variables aren't cleared when the script is called. The content of these variables is _not_ quoted. They may contain special characters. They are under the control of a possibly malicious remote user. diff --git a/docs/dynamic-user-mod.md b/docs/dynamic-user-mod.md index 989a2d14..ca3eb06c 100644 --- a/docs/dynamic-user-mod.md +++ b/docs/dynamic-user-mod.md @@ -8,7 +8,7 @@ The external program can read the following environment variables to get info ab - `SFTPGO_LOGIND_USER`, it contains the user trying to login serialized as JSON. A JSON serialized user id equal to zero means the user does not exist inside SFTPGo - `SFTPGO_LOGIND_METHOD`, possible values are: `password`, `publickey`, `keyboard-interactive`, `TLSCertificate` - `SFTPGO_LOGIND_IP`, ip address of the user trying to login -- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV` +- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP` The program must write, on its standard output: diff --git a/docs/external-auth.md b/docs/external-auth.md index db8d0355..113e0e1a 100644 --- a/docs/external-auth.md +++ b/docs/external-auth.md @@ -7,7 +7,7 @@ The external program can read the following environment variables to get info ab - `SFTPGO_AUTHD_USERNAME` - `SFTPGO_AUTHD_USER`, STPGo user serialized as JSON, empty if the user does not exist within the data provider - `SFTPGO_AUTHD_IP` -- `SFTPGO_AUTHD_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV` +- `SFTPGO_AUTHD_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP` - `SFTPGO_AUTHD_PASSWORD`, not empty for password authentication - `SFTPGO_AUTHD_PUBLIC_KEY`, not empty for public key authentication - `SFTPGO_AUTHD_KEYBOARD_INTERACTIVE`, not empty for keyboard interactive authentication @@ -26,7 +26,7 @@ If the hook is an HTTP URL then it will be invoked as HTTP POST. The request bod - `username` - `ip` - `user`, STPGo user serialized as JSON, omitted if the user does not exist within the data provider -- `protocol`, possible values are `SSH`, `FTP`, `DAV` +- `protocol`, possible values are `SSH`, `FTP`, `DAV`, `HTTP` - `password`, not empty for password authentication - `public_key`, not empty for public key authentication - `keyboard_interactive`, not empty for keyboard interactive authentication diff --git a/docs/full-configuration.md b/docs/full-configuration.md index d566ac25..70618a33 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -210,7 +210,8 @@ The configuration file contains the following sections: - `bindings`, list of structs. Each struct has the following fields: - `port`, integer. The port used for serving HTTP requests. Default: 8080. - `address`, string. 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: "127.0.0.1". - - `enable_web_admin`, boolean. Set to `false` to disable the built-in web admin for this binding. You also need to define `templates_path` and `static_files_path` to enable the built-in web admin interface. Default `true`. + - `enable_web_admin`, boolean. Set to `false` to disable the built-in web admin for this binding. You also need to define `templates_path` and `static_files_path` to use the built-in web admin interface. Default `true`. + - `enable_web_client`, boolean. Set to `false` to disable the built-in web client for this binding. You also need to define `templates_path` and `static_files_path` to use the built-in web client interface. Default `true`. - `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. @@ -219,7 +220,7 @@ The configuration file contains the following sections: - `templates_path`, string. Path to the HTML web templates. This can be an absolute path or a path relative to the config dir - `static_files_path`, string. Path to the static files for the web interface. This can be an absolute path or a path relative to the config dir. If both `templates_path` and `static_files_path` are empty the built-in web interface will be disabled - `backups_path`, string. Path to the backup directory. This can be an absolute path or a path relative to the config dir. We don't allow backups in arbitrary paths for security reasons - - `web_admin_root`, string. Defines a base URL for the web admin. If empty web admin resources will be available at the root ("/") URI. If defined it must be an absolute URI or it will be ignored + - `web_root`, string. Defines a base URL for the web admin and client interfaces. If empty web admin and client resources will be available at the root ("/") URI. If defined it must be an absolute URI or it will be ignored - `certificate_file`, string. Certificate for HTTPS. This can be an absolute path or a path relative to the config dir. - `certificate_key_file`, string. Private key matching the above certificate. This can be an absolute path or a path relative to the config dir. If both the certificate and the private key are provided, the server will expect HTTPS connections. Certificate and key files can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows. - `ca_certificates`, list of strings. Set of root certificate authorities to be used to verify client certificates. diff --git a/docs/howto/postgresql-s3.md b/docs/howto/postgresql-s3.md index 50da996f..4e932f53 100644 --- a/docs/howto/postgresql-s3.md +++ b/docs/howto/postgresql-s3.md @@ -200,7 +200,7 @@ sudo systemctl restart sftpgo So now open the Web Admin URL. -[http://127.0.0.1:8080/web](http://127.0.0.1:8080/web) +[http://127.0.0.1:8080/web/admin](http://127.0.0.1:8080/web/admin) Click `Add` and fill the user details, the minimum required parameters are: diff --git a/docs/post-login-hook.md b/docs/post-login-hook.md index 2b5d59ba..ff310382 100644 --- a/docs/post-login-hook.md +++ b/docs/post-login-hook.md @@ -12,7 +12,7 @@ If the hook defines an external program it can reads the following environment v - `SFTPGO_LOGIND_IP` - `SFTPGO_LOGIND_METHOD`, possible values are `publickey`, `password`, `keyboard-interactive`, `publickey+password`, `publickey+keyboard-interactive`, `TLSCertificate`, `TLSCertificate+password` or `no_auth_tryed` - `SFTPGO_LOGIND_STATUS`, 1 means login OK, 0 login KO -- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV` +- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP` Previous global environment variables aren't cleared when the script is called. The program must finish within 20 seconds. diff --git a/docs/web-admin.md b/docs/web-admin.md index efc80149..44929a88 100644 --- a/docs/web-admin.md +++ b/docs/web-admin.md @@ -3,7 +3,7 @@ You can easily build your own interface using the exposed [REST API](./rest-api.md). Anyway, SFTPGo also provides a basic built-in web interface that allows you to manage users, virtual folders, admins and connections. With the default `httpd` configuration, the web admin is available at the following URL: -[http://127.0.0.1:8080/web](http://127.0.0.1:8080/web) +[http://127.0.0.1:8080/web/admin](http://127.0.0.1:8080/web/admin) The default credentials are: diff --git a/docs/web-client.md b/docs/web-client.md new file mode 100644 index 00000000..a166f8ae --- /dev/null +++ b/docs/web-client.md @@ -0,0 +1,10 @@ +# Web Client + +SFTPGo provides a basic front-end web interface for your users. It allows end-users to browse and download their files and change their credentials. + +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. + +With the default `httpd` configuration, the web admin 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/ftpd/cryptfs_test.go b/ftpd/cryptfs_test.go index ef6e5e57..b449e76e 100644 --- a/ftpd/cryptfs_test.go +++ b/ftpd/cryptfs_test.go @@ -1,6 +1,10 @@ package ftpd_test import ( + "crypto/sha256" + "fmt" + "hash" + "io" "net/http" "os" "path" @@ -200,12 +204,45 @@ func TestResumeCryptFs(t *testing.T) { assert.NoError(t, err) assert.Equal(t, data, readed) } + // now test a download resume using a bigger file + testFileSize := int64(655352) + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + initialHash, err := computeHashForFile(sha256.New(), testFilePath) + assert.NoError(t, err) + err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0) + assert.NoError(t, err) + err = ftpDownloadFile(testFileName, localDownloadPath, testFileSize, client, 0) + assert.NoError(t, err) + downloadHash, err := computeHashForFile(sha256.New(), localDownloadPath) + assert.NoError(t, err) + assert.Equal(t, initialHash, downloadHash) + err = os.Truncate(localDownloadPath, 32767) + assert.NoError(t, err) + err = ftpDownloadFile(testFileName, localDownloadPath+"_partial", testFileSize-32767, client, 32767) + assert.NoError(t, err) + file, err := os.OpenFile(localDownloadPath, os.O_APPEND|os.O_WRONLY, os.ModePerm) + assert.NoError(t, err) + file1, err := os.Open(localDownloadPath + "_partial") + assert.NoError(t, err) + _, err = io.Copy(file, file1) + assert.NoError(t, err) + err = file.Close() + assert.NoError(t, err) + err = file1.Close() + assert.NoError(t, err) + downloadHash, err = computeHashForFile(sha256.New(), localDownloadPath) + assert.NoError(t, err) + assert.Equal(t, initialHash, downloadHash) + err = client.Quit() assert.NoError(t, err) err = os.Remove(testFilePath) assert.NoError(t, err) err = os.Remove(localDownloadPath) assert.NoError(t, err) + err = os.Remove(localDownloadPath + "_partial") + assert.NoError(t, err) } _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) @@ -224,3 +261,17 @@ func getEncryptedFileSize(size int64) (int64, error) { encSize, err := sio.EncryptedSize(uint64(size)) return int64(encSize) + 33, err } + +func computeHashForFile(hasher hash.Hash, path string) (string, error) { + hash := "" + f, err := os.Open(path) + if err != nil { + return hash, err + } + defer f.Close() + _, err = io.Copy(hasher, f) + if err == nil { + hash = fmt.Sprintf("%x", hasher.Sum(nil)) + } + return hash, err +} diff --git a/ftpd/server.go b/ftpd/server.go index 1661b702..153858de 100644 --- a/ftpd/server.go +++ b/ftpd/server.go @@ -181,7 +181,7 @@ func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string) if err != nil { user.Username = username updateLoginMetrics(&user, ipAddr, loginMethod, err) - return nil, err + return nil, dataprovider.ErrInvalidCredentials } connection, err := s.validateUser(user, cc, loginMethod) @@ -211,7 +211,7 @@ func (s *Server) VerifyConnection(cc ftpserver.ClientContext, user string, tlsCo if err != nil { dbUser.Username = user updateLoginMetrics(&dbUser, ipAddr, dataprovider.LoginMethodTLSCertificate, err) - return nil, err + return nil, dataprovider.ErrInvalidCredentials } if dbUser.IsTLSUsernameVerificationEnabled() { dbUser, err = dataprovider.CheckUserAndTLSCert(user, ipAddr, common.ProtocolFTP, state.PeerCertificates[0]) diff --git a/go.mod b/go.mod index e2023a8a..ac019178 100644 --- a/go.mod +++ b/go.mod @@ -8,18 +8,18 @@ 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-20210326052512-e2135f7c9c77 - github.com/aws/aws-sdk-go v1.38.30 + github.com/aws/aws-sdk-go v1.38.35 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/fclairamb/ftpserverlib v0.13.1 - github.com/frankban/quicktest v1.12.0 // indirect + github.com/frankban/quicktest v1.12.1 // indirect github.com/go-chi/chi/v5 v5.0.3 github.com/go-chi/jwtauth/v5 v5.0.1 github.com/go-chi/render v1.0.1 github.com/go-ole/go-ole v1.2.5 // indirect github.com/go-sql-driver/mysql v1.6.0 - github.com/goccy/go-json v0.4.13 // indirect + github.com/goccy/go-json v0.4.14 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/snappy v0.0.3 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 @@ -45,7 +45,7 @@ require ( github.com/pires/go-proxyproto v0.5.0 github.com/pkg/sftp v1.13.0 github.com/prometheus/client_golang v1.10.0 - github.com/prometheus/common v0.21.0 // indirect + github.com/prometheus/common v0.23.0 // indirect github.com/rs/cors v1.7.1-0.20200626170627-8b4a00bd362b github.com/rs/xid v1.3.0 github.com/rs/zerolog v1.21.0 @@ -57,18 +57,19 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/viper v1.7.1 github.com/stretchr/testify v1.7.0 - github.com/studio-b12/gowebdav v0.0.0-20210203212356-8244b5a5f51a + github.com/studio-b12/gowebdav v0.0.0-20210427212133-86f8378cf140 github.com/yl2chen/cidranger v1.0.2 go.etcd.io/bbolt v1.3.5 go.uber.org/automaxprocs v1.4.0 gocloud.dev v0.22.0 gocloud.dev/secrets/hashivault v0.22.0 - golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b + golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf golang.org/x/mod v0.4.2 // indirect - golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6 - golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 + golang.org/x/net v0.0.0-20210505214959-0714010a04ed + golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6 golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba - google.golang.org/api v0.45.0 + google.golang.org/api v0.46.0 + google.golang.org/genproto v0.0.0-20210506142907-4a47615972c2 // indirect gopkg.in/ini.v1 v1.62.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect @@ -76,6 +77,6 @@ require ( replace ( github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 - golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20210425150317-089e67b931c9 - golang.org/x/net => github.com/drakkan/net v0.0.0-20210425150243-76901d0d25a8 + golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20210506192352-d548717d8e01 + golang.org/x/net => github.com/drakkan/net v0.0.0-20210506192712-36fee9de0f7d ) diff --git a/go.sum b/go.sum index 2144cb59..6ebd4b1c 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.36.1/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= -github.com/aws/aws-sdk-go v1.38.30 h1:X+JDSwkpSQfoLqH4fBLmS0rou8W/cdCCCD5lntTk9Vs= -github.com/aws/aws-sdk-go v1.38.30/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go v1.38.35 h1:7AlAO0FC+8nFjxiGKEmq0QLpiA8/XFr6eIxgRTwkdTg= +github.com/aws/aws-sdk-go v1.38.35/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= @@ -203,12 +203,12 @@ github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4Kfc github.com/docker/docker v1.4.2-0.20200319182547-c7ad2b866182/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/drakkan/crypto v0.0.0-20210425150317-089e67b931c9 h1:YbA8/6NJyj8440KNCDlEKEWfzjqjPcJj3Pwb5NGen0Q= -github.com/drakkan/crypto v0.0.0-20210425150317-089e67b931c9/go.mod h1:L66LXwR5jyrcyKbWcx1/J/356ka7Lqn6zsxuVf6JhiI= +github.com/drakkan/crypto v0.0.0-20210506192352-d548717d8e01 h1:OBfr3EGq6DjnxhskpY5Q5TYoMUyJexusO9153DaFad0= +github.com/drakkan/crypto v0.0.0-20210506192352-d548717d8e01/go.mod h1:M1JpE4lvRI5LLrE7yTCWfhbsy5rx3oZVjGZad4XMwWc= github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA= github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU= -github.com/drakkan/net v0.0.0-20210425150243-76901d0d25a8 h1:ENgPnm7K0a6nQbS/i+o/CvrTr2VRxN/r/he71GkT1+8= -github.com/drakkan/net v0.0.0-20210425150243-76901d0d25a8/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +github.com/drakkan/net v0.0.0-20210506192712-36fee9de0f7d h1:DUoPZEMoXDONtqVGBv8sGpYqZ0HgpWtdM3v9g2+dZAI= +github.com/drakkan/net v0.0.0-20210506192712-36fee9de0f7d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= @@ -235,8 +235,8 @@ github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHqu github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= github.com/frankban/quicktest v1.10.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y= -github.com/frankban/quicktest v1.12.0 h1:qlT496aO6ryji0l8Of/hVQCRkqNVyIbJtUZ1zDp6xyw= -github.com/frankban/quicktest v1.12.0/go.mod h1:qLE0fzW0VuyUAJgPU19zByoIr0HtCHN/r/VLSOOIySU= +github.com/frankban/quicktest v1.12.1 h1:P6vQcHwZYgVGIpUzKB5DXzkEeYJppJOStPLuh9aB89c= +github.com/frankban/quicktest v1.12.1/go.mod h1:qLE0fzW0VuyUAJgPU19zByoIr0HtCHN/r/VLSOOIySU= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= @@ -288,8 +288,8 @@ github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22 github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/goccy/go-json v0.4.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-json v0.4.13 h1:ZclJ9a9KEbGUxWkqPkh7ZscnnPAsGIinyg/dJlIph1Y= -github.com/goccy/go-json v0.4.13/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.4.14 h1:RR3AVWMEfVW0Z/DbfhxiLrv5mYlwlUmCK8jMtyCcSls= +github.com/goccy/go-json v0.4.14/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= @@ -751,8 +751,8 @@ github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt2 github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.18.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= -github.com/prometheus/common v0.21.0 h1:SMvI2JVldvfUvRVlP64jkIJEC6WiGHJcN2e5tB+ztF8= -github.com/prometheus/common v0.21.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.23.0 h1:GXWvPYuTUenIa+BhOq/x+L/QZzCqASkVRny5KTlPDGM= +github.com/prometheus/common v0.23.0/go.mod h1:H6QK/N6XVT42whUeIdI3dp36w49c+/iMDk7UAI2qm7Q= github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= @@ -840,8 +840,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/studio-b12/gowebdav v0.0.0-20210203212356-8244b5a5f51a h1:Zq18I/ONL/ynTg+mhn78lyh14vNjOqaWfLKVZDwFUk4= -github.com/studio-b12/gowebdav v0.0.0-20210203212356-8244b5a5f51a/go.mod h1:gCcfDlA1Y7GqOaeEKw5l9dOGx1VLdc/HuQSlQAaZ30s= +github.com/studio-b12/gowebdav v0.0.0-20210427212133-86f8378cf140 h1:JCSn/2k3AQ0aJGs5Yx2xv6qrW0CAULc1E+xtSxeeQ/E= +github.com/studio-b12/gowebdav v0.0.0-20210427212133-86f8378cf140/go.mod h1:gCcfDlA1Y7GqOaeEKw5l9dOGx1VLdc/HuQSlQAaZ30s= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tklauser/go-sysconf v0.3.4/go.mod h1:Cl2c8ZRWfHD5IrfHo9VN+FX9kCFjIOyVklgXycLB6ek= @@ -943,8 +943,9 @@ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78 h1:rPRtHfUb0UKZeZ6GH4K4Nt4YRbE9V1u+QZX5upZXqJQ= golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c h1:SgVl/sCtkicsS7psKkje4H9YtjdEl3xsYh7N+5TDHqY= +golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1023,8 +1024,9 @@ golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 h1:iGu644GcxtEcrInvDsQRCwJjtCIOlT2V7IRt6ah2Whw= -golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6 h1:cdsMqa2nXzqlgs183pHxtvoVwU7CyzaCTAUOg94af4c= +golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1145,8 +1147,9 @@ google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34q google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.45.0 h1:pqMffJFLBVUDIoYsHcqtxgQVTsmxMDpYLOc5MT4Jrww= google.golang.org/api v0.45.0/go.mod h1:ISLIJCedJolbZvDfAk+Ctuq5hf+aJ33WgtUsfyFoLXA= +google.golang.org/api v0.46.0 h1:jkDWHOBIoNSD0OQpq4rtBVu+Rh325MPjXG1rakAp8JU= +google.golang.org/api v0.46.0/go.mod h1:ceL4oozhkAiTID8XMmJBsIxID/9wMXJVVFXPg4ylg3I= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1204,8 +1207,10 @@ google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20210413151531-c14fb6ef47c3/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210420162539-3c870d7478d2 h1:g2sJMUGCpeHZqTx8p3wsAWRS64nFq20i4dvJWcKGqvY= google.golang.org/genproto v0.0.0-20210420162539-3c870d7478d2/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/genproto v0.0.0-20210429181445-86c259c2b4ab/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/genproto v0.0.0-20210506142907-4a47615972c2 h1:pl8qT5D+48655f14yDURpIZwSPvMWuuekfAP+gxtjvk= +google.golang.org/genproto v0.0.0-20210506142907-4a47615972c2/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= diff --git a/httpd/auth_utils.go b/httpd/auth_utils.go index 57ba0532..29b4a447 100644 --- a/httpd/auth_utils.go +++ b/httpd/auth_utils.go @@ -18,9 +18,10 @@ import ( type tokenAudience = string const ( - tokenAudienceWeb tokenAudience = "Web" - tokenAudienceAPI tokenAudience = "API" - tokenAudienceCSRF tokenAudience = "CSRF" + tokenAudienceWebAdmin tokenAudience = "WebAdmin" + tokenAudienceWebClient tokenAudience = "WebClient" + tokenAudienceAPI tokenAudience = "API" + tokenAudienceCSRF tokenAudience = "CSRF" ) const ( @@ -119,15 +120,21 @@ func (c *jwtTokenClaims) createTokenResponse(tokenAuth *jwtauth.JWTAuth, audienc return response, nil } -func (c *jwtTokenClaims) createAndSetCookie(w http.ResponseWriter, r *http.Request, tokenAuth *jwtauth.JWTAuth) error { - resp, err := c.createTokenResponse(tokenAuth, tokenAudienceWeb) +func (c *jwtTokenClaims) createAndSetCookie(w http.ResponseWriter, r *http.Request, tokenAuth *jwtauth.JWTAuth, audience tokenAudience) error { + resp, err := c.createTokenResponse(tokenAuth, audience) if err != nil { return err } + var basePath string + if audience == tokenAudienceWebAdmin { + basePath = webBaseAdminPath + } else { + basePath = webBaseClientPath + } http.SetCookie(w, &http.Cookie{ Name: "jwt", Value: resp["access_token"].(string), - Path: webBasePath, + Path: basePath, Expires: time.Now().Add(tokenDuration), HttpOnly: true, Secure: r.TLS != nil, @@ -178,6 +185,19 @@ func invalidateToken(r *http.Request) { } } +func getUserFromToken(r *http.Request) *dataprovider.User { + user := &dataprovider.User{} + _, claims, err := jwtauth.FromContext(r.Context()) + if err != nil { + return user + } + tokenClaims := jwtTokenClaims{} + tokenClaims.Decode(claims) + user.Username = tokenClaims.Username + user.Filters.WebClient = tokenClaims.Permissions + return user +} + func getAdminFromToken(r *http.Request) *dataprovider.Admin { admin := &dataprovider.Admin{} _, claims, err := jwtauth.FromContext(r.Context()) diff --git a/httpd/file.go b/httpd/file.go new file mode 100644 index 00000000..a0713ca6 --- /dev/null +++ b/httpd/file.go @@ -0,0 +1,87 @@ +package httpd + +import ( + "errors" + "io" + "sync/atomic" + + "github.com/eikenb/pipeat" + + "github.com/drakkan/sftpgo/common" +) + +var errTransferAborted = errors.New("transfer aborted") + +type httpdFile struct { + *common.BaseTransfer + reader io.ReadCloser + isFinished bool +} + +func newHTTPDFile(baseTransfer *common.BaseTransfer, pipeReader *pipeat.PipeReaderAt) *httpdFile { + var reader io.ReadCloser + if baseTransfer.File != nil { + reader = baseTransfer.File + } else if pipeReader != nil { + reader = pipeReader + } + return &httpdFile{ + BaseTransfer: baseTransfer, + reader: reader, + isFinished: false, + } +} + +// Read reads the contents to downloads. +func (f *httpdFile) Read(p []byte) (n int, err error) { + if atomic.LoadInt32(&f.AbortTransfer) == 1 { + return 0, errTransferAborted + } + + f.Connection.UpdateLastActivity() + + n, err = f.reader.Read(p) + atomic.AddInt64(&f.BytesSent, int64(n)) + + if err != nil && err != io.EOF { + f.TransferError(err) + return + } + f.HandleThrottle() + return +} + +// Close closes the current transfer +func (f *httpdFile) Close() error { + if err := f.setFinished(); err != nil { + return err + } + err := f.closeIO() + errBaseClose := f.BaseTransfer.Close() + if errBaseClose != nil { + err = errBaseClose + } + + return f.Connection.GetFsError(f.Fs, err) +} + +func (f *httpdFile) closeIO() error { + var err error + if f.File != nil { + err = f.File.Close() + } else if f.reader != nil { + err = f.reader.Close() + } + return err +} + +func (f *httpdFile) setFinished() error { + f.Lock() + defer f.Unlock() + + if f.isFinished { + return common.ErrTransferClosed + } + f.isFinished = true + return nil +} diff --git a/httpd/handler.go b/httpd/handler.go new file mode 100644 index 00000000..91fbd283 --- /dev/null +++ b/httpd/handler.go @@ -0,0 +1,104 @@ +package httpd + +import ( + "io" + "net/http" + "os" + "path" + "strings" + + "github.com/drakkan/sftpgo/common" + "github.com/drakkan/sftpgo/dataprovider" + "github.com/drakkan/sftpgo/logger" + "github.com/drakkan/sftpgo/utils" +) + +// Connection details for a HTTP connection used to inteact with an SFTPGo filesystem +type Connection struct { + *common.BaseConnection + request *http.Request +} + +// GetClientVersion returns the connected client's version. +func (c *Connection) GetClientVersion() string { + if c.request != nil { + return c.request.UserAgent() + } + return "" +} + +// GetRemoteAddress return the connected client's address +func (c *Connection) GetRemoteAddress() string { + if c.request != nil { + return c.request.RemoteAddr + } + return "" +} + +// Disconnect closes the active transfer +func (c *Connection) Disconnect() (err error) { + return c.SignalTransfersAbort() +} + +// GetCommand returns the request method +func (c *Connection) GetCommand() string { + if c.request != nil { + return strings.ToUpper(c.request.Method) + } + return "" +} + +// Stat returns a FileInfo describing the named file/directory, or an error, +// if any happens +func (c *Connection) Stat(name string, mode int) (os.FileInfo, error) { + c.UpdateLastActivity() + + name = utils.CleanPath(name) + if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(name)) { + return nil, c.GetPermissionDeniedError() + } + + fi, err := c.DoStat(name, mode) + if err != nil { + c.Log(logger.LevelDebug, "error running stat on path %#v: %+v", name, err) + return nil, err + } + return fi, err +} + +// ReadDir returns a list of directory entries +func (c *Connection) ReadDir(name string) ([]os.FileInfo, error) { + c.UpdateLastActivity() + + name = utils.CleanPath(name) + return c.ListDir(name) +} + +func (c *Connection) getFileReader(name string, offset int64) (io.ReadCloser, error) { + c.UpdateLastActivity() + + name = utils.CleanPath(name) + if !c.User.HasPerm(dataprovider.PermDownload, path.Dir(name)) { + return nil, os.ErrPermission + } + + if !c.User.IsFileAllowed(name) { + c.Log(logger.LevelWarn, "reading file %#v is not allowed", name) + return nil, os.ErrPermission + } + + fs, p, err := c.GetFsAndResolvedPath(name) + if err != nil { + return nil, err + } + + file, r, cancelFn, err := fs.Open(p, offset) + if err != nil { + c.Log(logger.LevelWarn, "could not open file %#v for reading: %+v", p, err) + return nil, c.GetFsError(fs, err) + } + + baseTransfer := common.NewBaseTransfer(file, c.BaseConnection, cancelFn, p, name, common.TransferDownload, + 0, 0, 0, false, fs) + return newHTTPDFile(baseTransfer, r), nil +} diff --git a/httpd/httpd.go b/httpd/httpd.go index 30a3285d..fad1b9ca 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -29,47 +29,55 @@ import ( ) const ( - logSender = "httpd" - tokenPath = "/api/v2/token" - logoutPath = "/api/v2/logout" - activeConnectionsPath = "/api/v2/connections" - quotaScanPath = "/api/v2/quota-scans" - quotaScanVFolderPath = "/api/v2/folder-quota-scans" - userPath = "/api/v2/users" - versionPath = "/api/v2/version" - folderPath = "/api/v2/folders" - serverStatusPath = "/api/v2/status" - dumpDataPath = "/api/v2/dumpdata" - loadDataPath = "/api/v2/loaddata" - updateUsedQuotaPath = "/api/v2/quota-update" - updateFolderUsedQuotaPath = "/api/v2/folder-quota-update" - defenderBanTime = "/api/v2/defender/bantime" - defenderUnban = "/api/v2/defender/unban" - defenderScore = "/api/v2/defender/score" - adminPath = "/api/v2/admins" - adminPwdPath = "/api/v2/changepwd/admin" - healthzPath = "/healthz" - webRootPathDefault = "/" - webBasePathDefault = "/web" - webLoginPathDefault = "/web/login" - webLogoutPathDefault = "/web/logout" - webUsersPathDefault = "/web/users" - webUserPathDefault = "/web/user" - webConnectionsPathDefault = "/web/connections" - webFoldersPathDefault = "/web/folders" - webFolderPathDefault = "/web/folder" - webStatusPathDefault = "/web/status" - webAdminsPathDefault = "/web/admins" - webAdminPathDefault = "/web/admin" - webMaintenancePathDefault = "/web/maintenance" - webBackupPathDefault = "/web/backup" - webRestorePathDefault = "/web/restore" - webScanVFolderPathDefault = "/web/folder-quota-scans" - webQuotaScanPathDefault = "/web/quota-scans" - webChangeAdminPwdPathDefault = "/web/changepwd/admin" - webTemplateUserDefault = "/web/template/user" - webTemplateFolderDefault = "/web/template/folder" - webStaticFilesPathDefault = "/static" + logSender = "httpd" + tokenPath = "/api/v2/token" + logoutPath = "/api/v2/logout" + activeConnectionsPath = "/api/v2/connections" + quotaScanPath = "/api/v2/quota-scans" + quotaScanVFolderPath = "/api/v2/folder-quota-scans" + userPath = "/api/v2/users" + versionPath = "/api/v2/version" + folderPath = "/api/v2/folders" + serverStatusPath = "/api/v2/status" + dumpDataPath = "/api/v2/dumpdata" + loadDataPath = "/api/v2/loaddata" + updateUsedQuotaPath = "/api/v2/quota-update" + updateFolderUsedQuotaPath = "/api/v2/folder-quota-update" + defenderBanTime = "/api/v2/defender/bantime" + defenderUnban = "/api/v2/defender/unban" + defenderScore = "/api/v2/defender/score" + adminPath = "/api/v2/admins" + adminPwdPath = "/api/v2/changepwd/admin" + healthzPath = "/healthz" + webRootPathDefault = "/" + webBasePathDefault = "/web" + webBasePathAdminDefault = "/web/admin" + webBasePathClientDefault = "/web/client" + webLoginPathDefault = "/web/admin/login" + webLogoutPathDefault = "/web/admin/logout" + webUsersPathDefault = "/web/admin/users" + webUserPathDefault = "/web/admin/user" + webConnectionsPathDefault = "/web/admin/connections" + webFoldersPathDefault = "/web/admin/folders" + webFolderPathDefault = "/web/admin/folder" + webStatusPathDefault = "/web/admin/status" + webAdminsPathDefault = "/web/admin/managers" + webAdminPathDefault = "/web/admin/manager" + webMaintenancePathDefault = "/web/admin/maintenance" + webBackupPathDefault = "/web/admin/backup" + webRestorePathDefault = "/web/admin/restore" + webScanVFolderPathDefault = "/web/admin/folder-quota-scans" + webQuotaScanPathDefault = "/web/admin/quota-scans" + webChangeAdminPwdPathDefault = "/web/admin/changepwd" + webTemplateUserDefault = "/web/admin/template/user" + webTemplateFolderDefault = "/web/admin/template/folder" + webClientLoginPathDefault = "/web/client/login" + webClientFilesPathDefault = "/web/client/files" + webClientCredentialsPathDefault = "/web/client/credentials" + webChangeClientPwdPathDefault = "/web/client/changepwd" + webChangeClientKeysPathDefault = "/web/client/managekeys" + webClientLogoutPathDefault = "/web/client/logout" + webStaticFilesPathDefault = "/static" // MaxRestoreSize defines the max size for the loaddata input file MaxRestoreSize = 10485760 // 10 MB maxRequestSize = 1048576 // 1MB @@ -77,37 +85,46 @@ const ( ) var ( - backupsPath string - certMgr *common.CertManager - jwtTokensCleanupTicker *time.Ticker - jwtTokensCleanupDone chan bool - invalidatedJWTTokens sync.Map - csrfTokenAuth *jwtauth.JWTAuth - webRootPath string - webBasePath string - webLoginPath string - webLogoutPath string - webUsersPath string - webUserPath string - webConnectionsPath string - webFoldersPath string - webFolderPath string - webStatusPath string - webAdminsPath string - webAdminPath string - webMaintenancePath string - webBackupPath string - webRestorePath string - webScanVFolderPath string - webQuotaScanPath string - webChangeAdminPwdPath string - webTemplateUser string - webTemplateFolder string - webStaticFilesPath string + backupsPath string + certMgr *common.CertManager + jwtTokensCleanupTicker *time.Ticker + jwtTokensCleanupDone chan bool + invalidatedJWTTokens sync.Map + csrfTokenAuth *jwtauth.JWTAuth + webRootPath string + webBasePath string + webBaseAdminPath string + webBaseClientPath string + webLoginPath string + webLogoutPath string + webUsersPath string + webUserPath string + webConnectionsPath string + webFoldersPath string + webFolderPath string + webStatusPath string + webAdminsPath string + webAdminPath string + webMaintenancePath string + webBackupPath string + webRestorePath string + webScanVFolderPath string + webQuotaScanPath string + webChangeAdminPwdPath string + webTemplateUser string + webTemplateFolder string + webClientLoginPath string + webClientFilesPath string + webClientCredentialsPath string + webChangeClientPwdPath string + webChangeClientKeysPath string + webClientLogoutPath string + webStaticFilesPath string ) func init() { updateWebAdminURLs("") + updateWebClientURLs("") } // Binding defines the configuration for a network listener @@ -119,6 +136,9 @@ type Binding struct { // Enable the built-in admin interface. // You have to define TemplatesPath and StaticFilesPath for this to work EnableWebAdmin bool `json:"enable_web_admin" mapstructure:"enable_web_admin"` + // Enable the built-in client interface. + // You have to define TemplatesPath and StaticFilesPath for this to work + EnableWebClient bool `json:"enable_web_client" mapstructure:"enable_web_client"` // you also need to provide a certificate for enabling HTTPS EnableHTTPS bool `json:"enable_https" mapstructure:"enable_https"` // set to 1 to require client certificate authentication in addition to basic auth. @@ -181,9 +201,9 @@ type Conf struct { StaticFilesPath string `json:"static_files_path" mapstructure:"static_files_path"` // Path to the backup directory. This can be an absolute path or a path relative to the config dir BackupsPath string `json:"backups_path" mapstructure:"backups_path"` - // Defines a base URL for the web admin. If empty web admin resources will be available at the - // root ("/") URI. If defined it must be an absolute URI or it will be ignored. - WebAdminRoot string `json:"web_admin_root" mapstructure:"web_admin_root"` + // Defines a base URL for the web admin and client interfaces. If empty web admin and client resources will + // be available at the root ("/") URI. If defined it must be an absolute URI or it will be ignored. + WebRoot string `json:"web_root" mapstructure:"web_root"` // If files containing a certificate and matching private key for the server are provided the server will expect // HTTPS connections. // Certificate and key files can be reloaded on demand sending a "SIGHUP" signal on Unix based systems and a @@ -213,27 +233,50 @@ func (c *Conf) ShouldBind() bool { return false } +func (c *Conf) isWebAdminEnabled() bool { + for _, binding := range c.Bindings { + if binding.EnableWebAdmin { + return true + } + } + return false +} + +func (c *Conf) isWebClientEnabled() bool { + for _, binding := range c.Bindings { + if binding.EnableWebClient { + return true + } + } + return false +} + // Initialize configures and starts the HTTP server func (c *Conf) Initialize(configDir string) error { logger.Debug(logSender, "", "initializing HTTP server with config %+v", c) backupsPath = getConfigPath(c.BackupsPath, configDir) staticFilesPath := getConfigPath(c.StaticFilesPath, configDir) templatesPath := getConfigPath(c.TemplatesPath, configDir) - enableWebAdmin := staticFilesPath != "" || templatesPath != "" if backupsPath == "" { return fmt.Errorf("required directory is invalid, backup path %#v", backupsPath) } - if enableWebAdmin && (staticFilesPath == "" || templatesPath == "") { + if (c.isWebAdminEnabled() || c.isWebClientEnabled()) && (staticFilesPath == "" || templatesPath == "") { return fmt.Errorf("required directory is invalid, static file path: %#v template path: %#v", staticFilesPath, templatesPath) } certificateFile := getConfigPath(c.CertificateFile, configDir) certificateKeyFile := getConfigPath(c.CertificateKeyFile, configDir) - if enableWebAdmin { - updateWebAdminURLs(c.WebAdminRoot) - loadTemplates(templatesPath) + if c.isWebAdminEnabled() { + updateWebAdminURLs(c.WebRoot) + loadAdminTemplates(templatesPath) } else { - logger.Info(logSender, "", "built-in web interface disabled, please set templates_path and static_files_path to enable it") + logger.Info(logSender, "", "built-in web admin interface disabled") + } + if c.isWebClientEnabled() { + updateWebClientURLs(c.WebRoot) + loadClientTemplates(templatesPath) + } else { + logger.Info(logSender, "", "built-in web client interface disabled") } if certificateFile != "" && certificateKeyFile != "" { mgr, err := common.NewCertManager(certificateFile, certificateKeyFile, configDir, logSender) @@ -261,7 +304,7 @@ func (c *Conf) Initialize(configDir string) error { } go func(b Binding) { - server := newHttpdServer(b, staticFilesPath, enableWebAdmin) + server := newHttpdServer(b, staticFilesPath) exitChannel <- server.listenAndServe() }(binding) @@ -271,10 +314,14 @@ func (c *Conf) Initialize(configDir string) error { return <-exitChannel } -func isWebAdminRequest(r *http.Request) bool { +func isWebRequest(r *http.Request) bool { return strings.HasPrefix(r.RequestURI, webBasePath+"/") } +func isWebClientRequest(r *http.Request) bool { + return strings.HasPrefix(r.RequestURI, webBaseClientPath+"/") +} + // ReloadCertificateMgr reloads the certificate manager func ReloadCertificateMgr() error { if certMgr != nil { @@ -330,12 +377,28 @@ func fileServer(r chi.Router, path string, root http.FileSystem) { }) } +func updateWebClientURLs(baseURL string) { + if !path.IsAbs(baseURL) { + baseURL = "/" + } + webRootPath = path.Join(baseURL, webRootPathDefault) + webBasePath = path.Join(baseURL, webBasePathDefault) + webBaseClientPath = path.Join(baseURL, webBasePathClientDefault) + webClientLoginPath = path.Join(baseURL, webClientLoginPathDefault) + webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault) + webClientCredentialsPath = path.Join(baseURL, webClientCredentialsPathDefault) + webChangeClientPwdPath = path.Join(baseURL, webChangeClientPwdPathDefault) + webChangeClientKeysPath = path.Join(baseURL, webChangeClientKeysPathDefault) + webClientLogoutPath = path.Join(baseURL, webClientLogoutPathDefault) +} + func updateWebAdminURLs(baseURL string) { if !path.IsAbs(baseURL) { baseURL = "/" } webRootPath = path.Join(baseURL, webRootPathDefault) webBasePath = path.Join(baseURL, webBasePathDefault) + webBaseAdminPath = path.Join(baseURL, webBasePathAdminDefault) webLoginPath = path.Join(baseURL, webLoginPathDefault) webLogoutPath = path.Join(baseURL, webLogoutPathDefault) webUsersPath = path.Join(baseURL, webUsersPathDefault) @@ -360,11 +423,12 @@ func updateWebAdminURLs(baseURL string) { // GetHTTPRouter returns an HTTP handler suitable to use for test cases func GetHTTPRouter() http.Handler { b := Binding{ - Address: "", - Port: 8080, - EnableWebAdmin: true, + Address: "", + Port: 8080, + EnableWebAdmin: true, + EnableWebClient: true, } - server := newHttpdServer(b, "../static", true) + server := newHttpdServer(b, "../static") server.initializeRouter() return server.router } diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index c6e2cbc6..73ec9c63 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -19,6 +19,7 @@ import ( "runtime" "strconv" "strings" + "sync" "testing" "time" @@ -39,6 +40,7 @@ import ( "github.com/drakkan/sftpgo/httpdtest" "github.com/drakkan/sftpgo/kms" "github.com/drakkan/sftpgo/logger" + "github.com/drakkan/sftpgo/sftpd" "github.com/drakkan/sftpgo/utils" "github.com/drakkan/sftpgo/vfs" ) @@ -68,22 +70,31 @@ const ( logoutPath = "/api/v2/logout" healthzPath = "/healthz" webBasePath = "/web" - webLoginPath = "/web/login" - webLogoutPath = "/web/logout" - webUsersPath = "/web/users" - webUserPath = "/web/user" - webFoldersPath = "/web/folders" - webFolderPath = "/web/folder" - webConnectionsPath = "/web/connections" - webStatusPath = "/web/status" - webAdminsPath = "/web/admins" - webAdminPath = "/web/admin" - webMaintenancePath = "/web/maintenance" - webRestorePath = "/web/restore" - webChangeAdminPwdPath = "/web/changepwd/admin" - webTemplateUser = "/web/template/user" - webTemplateFolder = "/web/template/folder" + webBasePathAdmin = "/web/admin" + webLoginPath = "/web/admin/login" + webLogoutPath = "/web/admin/logout" + webUsersPath = "/web/admin/users" + webUserPath = "/web/admin/user" + webFoldersPath = "/web/admin/folders" + webFolderPath = "/web/admin/folder" + webConnectionsPath = "/web/admin/connections" + webStatusPath = "/web/admin/status" + webAdminsPath = "/web/admin/managers" + webAdminPath = "/web/admin/manager" + webMaintenancePath = "/web/admin/maintenance" + webRestorePath = "/web/admin/restore" + webChangeAdminPwdPath = "/web/admin/changepwd" + webTemplateUser = "/web/admin/template/user" + webTemplateFolder = "/web/admin/template/folder" + webBasePathClient = "/web/client" + webClientLoginPath = "/web/client/login" + webClientFilesPath = "/web/client/files" + webClientCredentialsPath = "/web/client/credentials" + webChangeClientPwdPath = "/web/client/changepwd" + webChangeClientKeysPath = "/web/client/managekeys" + webClientLogoutPath = "/web/client/logout" httpBaseURL = "http://127.0.0.1:8081" + sftpServerAddr = "127.0.0.1:8022" configDir = ".." httpsCert = `-----BEGIN CERTIFICATE----- MIICHTCCAaKgAwIBAgIUHnqw7QnB1Bj9oUsNpdb+ZkFPOxMwCgYIKoZIzj0EAwIw @@ -117,6 +128,7 @@ AAAEA0E24gi8ab/XRSvJ85TGZJMe6HVmwxSG4ExPfTMwwe2n5EHjI1NnP2Yc6RrDBSJs11 -----END OPENSSH PRIVATE KEY-----` sftpPkeyFingerprint = "SHA256:QVQ06XHZZbYZzqfrsZcf3Yozy2WTnqQPeLOkcJCdbP0" redactedSecret = "[**redacted**]" + osWindows = "windows" ) var ( @@ -126,6 +138,7 @@ var ( credentialsPath string testServer *httptest.Server providerDriverName string + postConnectPath string ) type fakeConnection struct { @@ -154,6 +167,7 @@ func TestMain(m *testing.M) { homeBasePath = os.TempDir() logfilePath := filepath.Join(configDir, "sftpgo_api_test.log") logger.InitLogger(logfilePath, 5, 1, 28, false, zerolog.DebugLevel) + os.Setenv("SFTPGO_COMMON__UPLOAD_MODE", "2") err := config.LoadConfig(configDir, "") if err != nil { logger.WarnToConsole("error loading configuration: %v", err) @@ -178,6 +192,8 @@ func TestMain(m *testing.M) { os.Exit(1) } + postConnectPath = filepath.Join(homeBasePath, "postconnect.sh") + httpConfig := config.GetHTTPConfig() httpConfig.Initialize(configDir) //nolint:errcheck kmsConfig := config.GetKMSConfig() @@ -199,6 +215,16 @@ func TestMain(m *testing.M) { os.Exit(1) } + // required to test sftpfs + sftpdConf := config.GetSFTPDConfig() + sftpdConf.Bindings = []sftpd.Binding{ + { + Port: 8022, + }, + } + hostKeyPath := filepath.Join(os.TempDir(), "id_rsa") + sftpdConf.HostKeys = []string{hostKeyPath} + go func() { if err := httpdConf.Initialize(configDir); err != nil { logger.ErrorToConsole("could not start HTTP server: %v", err) @@ -206,7 +232,15 @@ func TestMain(m *testing.M) { } }() + go func() { + if err := sftpdConf.Initialize(configDir); err != nil { + logger.ErrorToConsole("could not start SFTP server: %v", err) + os.Exit(1) + } + }() + waitTCPListening(httpdConf.Bindings[0].GetAddress()) + waitTCPListening(sftpdConf.Bindings[0].GetAddress()) httpd.ReloadCertificateMgr() //nolint:errcheck // now start an https server certPath := filepath.Join(os.TempDir(), "test.crt") @@ -240,12 +274,15 @@ func TestMain(m *testing.M) { defer testServer.Close() exitCode := m.Run() - os.Remove(logfilePath) //nolint:errcheck - os.RemoveAll(backupsPath) //nolint:errcheck - os.RemoveAll(credentialsPath) //nolint:errcheck - os.Remove(certPath) //nolint:errcheck - os.Remove(keyPath) //nolint:errcheck - os.Exit(exitCode) //nolint:errcheck + os.Remove(logfilePath) + os.RemoveAll(backupsPath) + os.RemoveAll(credentialsPath) + os.Remove(certPath) + os.Remove(keyPath) + os.Remove(hostKeyPath) + os.Remove(hostKeyPath + ".pub") + os.Remove(postConnectPath) + os.Exit(exitCode) } func TestInitialization(t *testing.T) { @@ -253,6 +290,8 @@ func TestInitialization(t *testing.T) { assert.NoError(t, err) invalidFile := "invalid file" httpdConf := config.GetHTTPDConfig() + defaultTemplatesPath := httpdConf.TemplatesPath + defaultStaticPath := httpdConf.StaticFilesPath httpdConf.BackupsPath = backupsPath httpdConf.CertificateFile = invalidFile httpdConf.CertificateKeyFile = invalidFile @@ -264,6 +303,7 @@ func TestInitialization(t *testing.T) { err = httpdConf.Initialize(configDir) assert.Error(t, err) httpdConf = config.GetHTTPDConfig() + httpdConf.TemplatesPath = defaultTemplatesPath httpdConf.BackupsPath = ".." err = httpdConf.Initialize(configDir) assert.Error(t, err) @@ -274,6 +314,8 @@ func TestInitialization(t *testing.T) { httpdConf.TemplatesPath = "" err = httpdConf.Initialize(configDir) assert.Error(t, err) + httpdConf.StaticFilesPath = defaultStaticPath + httpdConf.TemplatesPath = defaultTemplatesPath httpdConf.CertificateFile = filepath.Join(os.TempDir(), "test.crt") httpdConf.CertificateKeyFile = filepath.Join(os.TempDir(), "test.key") httpdConf.CACertificates = append(httpdConf.CACertificates, invalidFile) @@ -284,6 +326,8 @@ func TestInitialization(t *testing.T) { err = httpdConf.Initialize(configDir) assert.Error(t, err) httpdConf.CARevocationLists = nil + httpdConf.Bindings[0].EnableWebAdmin = false + httpdConf.Bindings[0].EnableWebClient = false httpdConf.Bindings[0].Port = 8081 httpdConf.Bindings[0].EnableHTTPS = true httpdConf.Bindings[0].ClientAuthType = 1 @@ -302,6 +346,7 @@ func TestBasicUserHandling(t *testing.T) { user.ExpirationDate = utils.GetTimeAsMsSinceEpoch(time.Now()) user.AdditionalInfo = "some free text" user.Filters.TLSUsername = dataprovider.TLSUsernameCN + user.Filters.WebClient = append(user.Filters.WebClient, dataprovider.WebClientPubKeyChangeDisabled) originalUser := user user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) @@ -674,6 +719,10 @@ func TestAddUserInvalidFilters(t *testing.T) { u.Filters.TLSUsername = "not a supported attribute" _, _, err = httpdtest.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) + u.Filters.TLSUsername = "" + u.Filters.WebClient = []string{"not a valid web client options"} + _, _, err = httpdtest.AddUser(u, http.StatusBadRequest) + assert.NoError(t, err) } func TestAddUserInvalidFsConfig(t *testing.T) { @@ -2867,7 +2916,7 @@ func TestDumpdata(t *testing.T) { assert.NoError(t, err, string(rawResp)) err = os.Remove(filepath.Join(backupsPath, "backup.json")) assert.NoError(t, err) - if runtime.GOOS != "windows" { + if runtime.GOOS != osWindows { err = os.Chmod(backupsPath, 0001) assert.NoError(t, err) _, _, err = httpdtest.Dumpdata("bck.json", "", "", http.StatusInternalServerError) @@ -3054,7 +3103,7 @@ func TestLoaddata(t *testing.T) { assert.NoError(t, err) _, _, err = httpdtest.Loaddata(backupFilePath+"a", "1", "", http.StatusBadRequest) assert.NoError(t, err) - if runtime.GOOS != "windows" { + if runtime.GOOS != osWindows { err = os.Chmod(backupFilePath, 0111) assert.NoError(t, err) _, _, err = httpdtest.Loaddata(backupFilePath, "1", "", http.StatusInternalServerError) @@ -4303,9 +4352,19 @@ func TestGetWebRootMock(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, "/", nil) rr := executeRequest(req) checkResponseCode(t, http.StatusMovedPermanently, rr) + assert.Equal(t, webClientLoginPath, rr.Header().Get("Location")) req, _ = http.NewRequest(http.MethodGet, webBasePath, nil) rr = executeRequest(req) checkResponseCode(t, http.StatusMovedPermanently, rr) + assert.Equal(t, webClientLoginPath, rr.Header().Get("Location")) + req, _ = http.NewRequest(http.MethodGet, webBasePathAdmin, nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusMovedPermanently, rr) + assert.Equal(t, webLoginPath, rr.Header().Get("Location")) + req, _ = http.NewRequest(http.MethodGet, webBasePathClient, nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusMovedPermanently, rr) + assert.Equal(t, webClientLoginPath, rr.Header().Get("Location")) } func TestWebNotFoundURI(t *testing.T) { @@ -4324,6 +4383,22 @@ func TestWebNotFoundURI(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + urlString = httpBaseURL + webBasePathClient + "/a" + req, err = http.NewRequest(http.MethodGet, urlString, nil) + assert.NoError(t, err) + resp, err = httpclient.GetHTTPClient().Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + req, err = http.NewRequest(http.MethodGet, urlString, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, "invalid client token") + resp, err = httpclient.GetHTTPClient().Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusNotFound, resp.StatusCode) } func TestLogout(t *testing.T) { @@ -4404,7 +4479,638 @@ func TestTokenAudience(t *testing.T) { assert.Equal(t, webLoginPath, rr.Header().Get("Location")) } -func TestWebLoginMock(t *testing.T) { +func TestWebClientLoginMock(t *testing.T) { + _, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.Error(t, err) + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + // a web token is not valid for API or WebAdmin usage + req, _ := http.NewRequest(http.MethodGet, serverStatusPath, nil) + setBearerForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusUnauthorized, rr) + assert.Contains(t, rr.Body.String(), "Your token audience is not valid") + req, _ = http.NewRequest(http.MethodGet, webStatusPath, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webLoginPath, rr.Header().Get("Location")) + // bearer should not work + req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil) + setBearerForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webClientLoginPath, rr.Header().Get("Location")) + // now try to render client pages + req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // now logout + req, _ = http.NewRequest(http.MethodGet, webClientLogoutPath, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webClientLoginPath, rr.Header().Get("Location")) + req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webClientLoginPath, rr.Header().Get("Location")) + + // get a new token and use it after removing the user + webToken, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + + csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath) + assert.NoError(t, err) + form := make(url.Values) + form.Set("public_keys", testPubKey) + form.Set(csrfFormToken, csrfToken) + req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) +} + +func TestWebClientLoginErrorsMock(t *testing.T) { + form := getLoginForm("", "", "") + req, _ := http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid credentials") + + form = getLoginForm(defaultUsername, defaultPassword, "") + req, _ = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "unable to verify form token") +} + +func TestWebClientMaxConnections(t *testing.T) { + oldValue := common.Config.MaxTotalConnections + common.Config.MaxTotalConnections = 1 + + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil) + setJWTCookieForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + // now add a fake connection + fs := vfs.NewOsFs("id", os.TempDir(), "") + connection := &httpd.Connection{ + BaseConnection: common.NewBaseConnection(fs.ConnectionID(), common.ProtocolHTTP, user), + } + common.Connections.Add(connection) + + _, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.Error(t, err) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "configured connections limit reached") + + common.Connections.Remove(connection.GetID()) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + assert.Len(t, common.Connections.GetStats(), 0) + + common.Config.MaxTotalConnections = oldValue +} + +func TestDefender(t *testing.T) { + oldConfig := config.GetCommonConfig() + + cfg := config.GetCommonConfig() + cfg.DefenderConfig.Enabled = true + cfg.DefenderConfig.Threshold = 3 + cfg.DefenderConfig.ScoreRateExceeded = 2 + + err := common.Initialize(cfg) + assert.NoError(t, err) + + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + remoteAddr := "172.16.5.6:9876" + + webToken, err := getJWTWebClientTokenFromTestServerWithAddr(defaultUsername, defaultPassword, remoteAddr) + assert.NoError(t, err) + req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil) + req.RemoteAddr = remoteAddr + setJWTCookieForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + for i := 0; i < 3; i++ { + _, err = getJWTWebClientTokenFromTestServerWithAddr(defaultUsername, "wrong pwd", remoteAddr) + assert.Error(t, err) + } + + _, err = getJWTWebClientTokenFromTestServerWithAddr(defaultUsername, defaultPassword, remoteAddr) + assert.Error(t, err) + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + req.RemoteAddr = remoteAddr + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "your IP address is banned") + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + req.RemoteAddr = remoteAddr + req.Header.Set("X-Real-IP", "127.0.0.1:2345") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "your IP address is banned") + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + err = common.Initialize(oldConfig) + assert.NoError(t, err) +} + +func TestPostConnectHook(t *testing.T) { + if runtime.GOOS == osWindows { + t.Skip("this test is not available on Windows") + } + common.Config.PostConnectHook = postConnectPath + + u := getTestUser() + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + err = os.WriteFile(postConnectPath, getPostConnectScriptContent(0), os.ModePerm) + assert.NoError(t, err) + + _, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + err = os.WriteFile(postConnectPath, getPostConnectScriptContent(1), os.ModePerm) + assert.NoError(t, err) + + _, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.Error(t, err) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + common.Config.PostConnectHook = "" +} + +func TestMaxSessions(t *testing.T) { + u := getTestUser() + u.MaxSessions = 1 + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + _, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + // now add a fake connection + fs := vfs.NewOsFs("id", os.TempDir(), "") + connection := &httpd.Connection{ + BaseConnection: common.NewBaseConnection(fs.ConnectionID(), common.ProtocolHTTP, user), + } + common.Connections.Add(connection) + _, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.Error(t, err) + common.Connections.Remove(connection.GetID()) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + assert.Len(t, common.Connections.GetStats(), 0) +} + +func TestLoginInvalidFs(t *testing.T) { + u := getTestUser() + u.FsConfig.Provider = vfs.GCSFilesystemProvider + u.FsConfig.GCSConfig.Bucket = "test" + u.FsConfig.GCSConfig.Credentials = kms.NewPlainSecret("invalid JSON for credentials") + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + + credentialsFile := filepath.Join(credentialsPath, fmt.Sprintf("%v_gcs_credentials.json", u.Username)) + if !filepath.IsAbs(credentialsFile) { + credentialsFile = filepath.Join(configDir, credentialsFile) + } + + // now remove the credentials file so the filesystem creation will fail + err = os.Remove(credentialsFile) + assert.NoError(t, err) + + _, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.Error(t, err) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestWebClientChangePwd(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 + webClientLoginPath) + assert.NoError(t, err) + + form := make(url.Values) + form.Set("current_password", defaultPassword) + form.Set("new_password1", defaultPassword) + form.Set("new_password2", defaultPassword) + // no csrf token + req, _ := http.NewRequest(http.MethodPost, webChangeClientPwdPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + req, _ = http.NewRequest(http.MethodPost, webChangeClientPwdPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "the new password must be different from the current one") + + form.Set("current_password", defaultPassword+"2") + form.Set("new_password1", defaultPassword+"1") + form.Set("new_password2", defaultPassword+"1") + req, _ = http.NewRequest(http.MethodPost, webChangeClientPwdPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "current password does not match") + + form.Set("current_password", defaultPassword) + form.Set("new_password1", defaultPassword+"1") + form.Set("new_password2", defaultPassword+"1") + req, _ = http.NewRequest(http.MethodPost, webChangeClientPwdPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webClientLoginPath, rr.Header().Get("Location")) + + _, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.Error(t, err) + _, err = getJWTWebClientTokenFromTestServer(defaultUsername+"1", defaultPassword+"1") + assert.Error(t, err) + _, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword+"1") + assert.NoError(t, err) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestWebClientChangePubKeys(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 + webClientLoginPath) + assert.NoError(t, err) + form := make(url.Values) + form.Set("public_keys", testPubKey) + // no csrf token + req, _ := http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "Your public keys has been successfully updated") + + user, _, err = httpdtest.GetUserByUsername(defaultUsername, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, user.PublicKeys, 1) + + form.Set("public_keys", "invalid") + req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "Validation error: could not parse key") + + user.Filters.WebClient = append(user.Filters.WebClient, dataprovider.WebClientPubKeyChangeDisabled) + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + webToken, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + form.Set(csrfFormToken, csrfToken) + form.Set("public_keys", testPubKey) + req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + 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 TestWebGetFiles(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + testFileName := "testfile" + testDir := "testdir" + testFileContents := []byte("file contents") + err = os.MkdirAll(filepath.Join(user.GetHomeDir(), testDir), os.ModePerm) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(user.GetHomeDir(), testFileName), testFileContents, os.ModePerm) + assert.NoError(t, err) + webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil) + setJWTCookieForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testDir, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Equal(t, testFileContents, rr.Body.Bytes()) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil) + req.Header.Set("Range", "bytes=2-") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusPartialContent, rr) + assert.Equal(t, testFileContents[2:], rr.Body.Bytes()) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil) + req.Header.Set("Range", "bytes=-2") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusPartialContent, rr) + assert.Equal(t, testFileContents[11:], rr.Body.Bytes()) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil) + req.Header.Set("Range", "bytes=-2,") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusRequestedRangeNotSatisfiable, rr) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil) + req.Header.Set("Range", "bytes=1a-") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusRequestedRangeNotSatisfiable, rr) + + req, _ = http.NewRequest(http.MethodHead, webClientFilesPath+"?path="+testFileName, nil) + req.Header.Set("Range", "bytes=2-") + req.Header.Set("If-Range", time.Now().UTC().Add(120*time.Second).Format(http.TimeFormat)) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusPartialContent, rr) + + req, _ = http.NewRequest(http.MethodHead, webClientFilesPath+"?path="+testFileName, nil) + req.Header.Set("Range", "bytes=2-") + req.Header.Set("If-Range", time.Now().UTC().Add(-120*time.Second).Format(http.TimeFormat)) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, _ = http.NewRequest(http.MethodHead, webClientFilesPath+"?path="+testFileName, nil) + req.Header.Set("If-Modified-Since", time.Now().UTC().Add(-120*time.Second).Format(http.TimeFormat)) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, _ = http.NewRequest(http.MethodHead, webClientFilesPath+"?path="+testFileName, nil) + req.Header.Set("If-Modified-Since", time.Now().UTC().Add(120*time.Second).Format(http.TimeFormat)) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotModified, rr) + + req, _ = http.NewRequest(http.MethodHead, webClientFilesPath+"?path="+testFileName, nil) + req.Header.Set("If-Unmodified-Since", time.Now().UTC().Add(-120*time.Second).Format(http.TimeFormat)) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusPreconditionFailed, rr) + + req, _ = http.NewRequest(http.MethodHead, webClientFilesPath+"?path="+testFileName, nil) + req.Header.Set("If-Unmodified-Since", time.Now().UTC().Add(120*time.Second).Format(http.TimeFormat)) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + user.Filters.DeniedProtocols = []string{common.ProtocolHTTP} + _, resp, err := httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err, string(resp)) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + user.Filters.DeniedProtocols = []string{common.ProtocolFTP} + user.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword} + _, resp, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err, string(resp)) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + setJWTCookieForReq(req, webToken) + 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 TestGetFilesSFTPBackend(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + u := getTestSFTPUser() + u.FsConfig.SFTPConfig.BufferSize = 2 + u.Permissions["/adir"] = nil + u.Permissions["/adir1"] = []string{dataprovider.PermListItems} + u.Filters.FilePatterns = []dataprovider.PatternsFilter{ + { + Path: "/adir2", + DeniedPatterns: []string{"*.txt"}, + }, + } + sftpUser, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + + testFileName := "testsftpfile" + testDir := "testsftpdir" + testFileContents := []byte("sftp file contents") + err = os.MkdirAll(filepath.Join(user.GetHomeDir(), testDir, "sub"), os.ModePerm) + assert.NoError(t, err) + err = os.MkdirAll(filepath.Join(user.GetHomeDir(), "adir1"), os.ModePerm) + assert.NoError(t, err) + err = os.MkdirAll(filepath.Join(user.GetHomeDir(), "adir2"), os.ModePerm) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(user.GetHomeDir(), testFileName), testFileContents, os.ModePerm) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(user.GetHomeDir(), "adir1", "afile"), testFileContents, os.ModePerm) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(user.GetHomeDir(), "adir2", "afile.txt"), testFileContents, os.ModePerm) + assert.NoError(t, err) + webToken, err := getJWTWebClientTokenFromTestServer(sftpUser.Username, defaultPassword) + assert.NoError(t, err) + req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil) + setJWTCookieForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+path.Join(testDir, "sub"), nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+path.Join(testDir, "missing"), nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "card-body text-form-error") + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path=adir/sub", nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "card-body text-form-error") + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path=adir1/afile", nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "card-body text-form-error") + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path=adir2/afile.txt", nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "card-body text-form-error") + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Equal(t, testFileContents, rr.Body.Bytes()) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil) + req.Header.Set("Range", "bytes=2-") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusPartialContent, rr) + assert.Equal(t, testFileContents[2:], rr.Body.Bytes()) + + _, err = httpdtest.RemoveUser(sftpUser, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(sftpUser.GetHomeDir()) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestClientUserClose(t *testing.T) { + u := getTestUser() + u.UploadBandwidth = 64 + u.DownloadBandwidth = 64 + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + testFileName := "file.dat" + testFileSize := int64(1048576) + testFilePath := filepath.Join(user.GetHomeDir(), testFileName) + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + var wg sync.WaitGroup + wg.Add(1) + go func() { + req, _ := http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil) + setJWTCookieForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + wg.Done() + }() + + assert.Eventually(t, func() bool { + for _, stat := range common.Connections.GetStats() { + if len(stat.Transfers) > 0 { + return true + } + } + return false + }, 1*time.Second, 50*time.Millisecond) + + for _, stat := range common.Connections.GetStats() { + common.Connections.Close(stat.ConnectionID) + } + wg.Wait() + assert.Eventually(t, func() bool { return len(common.Connections.GetStats()) == 0 }, + 1*time.Second, 100*time.Millisecond) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestWebAdminLoginMock(t *testing.T) { webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) @@ -4449,10 +5155,10 @@ func TestWebLoginMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusFound, rr) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) // now try using wrong credentials - form := getAdminLoginForm(defaultTokenAuthUser, "wrong pwd", csrfToken) + form := getLoginForm(defaultTokenAuthUser, "wrong pwd", csrfToken) req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) @@ -4466,7 +5172,7 @@ func TestWebLoginMock(t *testing.T) { _, _, err = httpdtest.AddAdmin(a, http.StatusCreated) assert.NoError(t, err) - form = getAdminLoginForm(altAdminUsername, altAdminPassword, csrfToken) + form = getLoginForm(altAdminUsername, altAdminPassword, csrfToken) req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.RemoteAddr = "127.1.1.1:1234" @@ -4489,7 +5195,7 @@ func TestWebLoginMock(t *testing.T) { assert.Contains(t, rr.Body.String(), "Login from IP 127.0.1.1:4567 is not allowed") // invalid csrf token - form = getAdminLoginForm(altAdminUsername, altAdminPassword, "invalid csrf") + form = getLoginForm(altAdminUsername, altAdminPassword, "invalid csrf") req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.RemoteAddr = "10.9.9.8:1234" @@ -4534,7 +5240,7 @@ func TestWebAdminPwdChange(t *testing.T) { token, err := getJWTWebTokenFromTestServer(admin.Username, altAdminPassword) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) req, _ := http.NewRequest(http.MethodGet, webChangeAdminPwdPath, nil) setJWTCookieForReq(req, token) @@ -4619,7 +5325,7 @@ func TestBasicWebUsersMock(t *testing.T) { setJWTCookieForReq(req, webToken) rr = executeRequest(req) checkResponseCode(t, http.StatusNotFound, rr) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) form := make(url.Values) form.Set("username", user.Username) @@ -4670,7 +5376,7 @@ func TestWebAdminBasicMock(t *testing.T) { admin := getTestAdmin() admin.Username = altAdminUsername admin.Password = altAdminPassword - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) form := make(url.Values) form.Set("username", admin.Username) @@ -4878,7 +5584,7 @@ func TestAdminUpdateSelfMock(t *testing.T) { assert.NoError(t, err) token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) form := make(url.Values) form.Set("username", admin.Username) @@ -4910,7 +5616,7 @@ func TestWebMaintenanceMock(t *testing.T) { setJWTCookieForReq(req, token) rr := executeRequest(req) checkResponseCode(t, http.StatusOK, rr) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) form := make(url.Values) @@ -5013,7 +5719,7 @@ func TestWebUserAddMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) user := getTestUser() user.UploadBandwidth = 32 @@ -5285,7 +5991,7 @@ func TestWebUserUpdateMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) user := getTestUser() userAsJSON := getUserAsJSON(t, user) @@ -5504,7 +6210,7 @@ func TestUserTemplateWithFoldersMock(t *testing.T) { token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) user := getTestUser() form := make(url.Values) @@ -5592,7 +6298,7 @@ func TestUserTemplateMock(t *testing.T) { user.FsConfig.S3Config.KeyPrefix = "somedir/subdir/" user.FsConfig.S3Config.UploadPartSize = 5 user.FsConfig.S3Config.UploadConcurrency = 4 - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) form := make(url.Values) form.Set(csrfFormToken, csrfToken) @@ -5709,7 +6415,7 @@ func TestFolderTemplateMock(t *testing.T) { mappedPath := filepath.Join(os.TempDir(), "%name%mapped%name%path") token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) form := make(url.Values) form.Set("name", folderName) @@ -5827,7 +6533,7 @@ func TestWebUserS3Mock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) user := getTestUser() userAsJSON := getUserAsJSON(t, user) @@ -5979,7 +6685,7 @@ func TestWebUserGCSMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) user := getTestUser() userAsJSON := getUserAsJSON(t, user) @@ -6081,7 +6787,7 @@ func TestWebUserAzureBlobMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) user := getTestUser() userAsJSON := getUserAsJSON(t, user) @@ -6204,7 +6910,7 @@ func TestWebUserCryptMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) user := getTestUser() userAsJSON := getUserAsJSON(t, user) @@ -6296,7 +7002,7 @@ func TestWebUserSFTPFsMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) user := getTestUser() userAsJSON := getUserAsJSON(t, user) @@ -6418,7 +7124,7 @@ func TestAddWebFoldersMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) mappedPath := filepath.Clean(os.TempDir()) folderName := filepath.Base(mappedPath) @@ -6492,7 +7198,7 @@ func TestS3WebFolderMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) mappedPath := filepath.Clean(os.TempDir()) folderName := filepath.Base(mappedPath) @@ -6615,7 +7321,7 @@ func TestUpdateWebFolderMock(t *testing.T) { assert.NoError(t, err) apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) folderName := "vfolderupdate" folderDesc := "updated desc" @@ -6808,7 +7514,7 @@ func TestWebFoldersMock(t *testing.T) { func TestProviderClosedMock(t *testing.T) { token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) dataprovider.Close() req, _ := http.NewRequest(http.MethodGet, webFoldersPath, nil) @@ -6886,7 +7592,7 @@ func TestWebConnectionsMock(t *testing.T) { checkResponseCode(t, http.StatusForbidden, rr) assert.Contains(t, rr.Body.String(), "Invalid token") - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) assert.NoError(t, err) req, _ = http.NewRequest(http.MethodDelete, path.Join(webConnectionsPath, "id"), nil) setJWTCookieForReq(req, token) @@ -6948,14 +7654,24 @@ func getTestUser() dataprovider.User { return user } +func getTestSFTPUser() dataprovider.User { + u := getTestUser() + u.Username = u.Username + "_sftp" + u.FsConfig.Provider = vfs.SFTPFilesystemProvider + u.FsConfig.SFTPConfig.Endpoint = sftpServerAddr + u.FsConfig.SFTPConfig.Username = defaultUsername + u.FsConfig.SFTPConfig.Password = kms.NewPlainSecret(defaultPassword) + return u +} + func getUserAsJSON(t *testing.T, user dataprovider.User) []byte { json, err := json.Marshal(user) assert.NoError(t, err) return json } -func getCSRFToken() (string, error) { - req, err := http.NewRequest(http.MethodGet, httpBaseURL+webLoginPath, nil) +func getCSRFToken(url string) (string, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return "", err } @@ -7001,7 +7717,7 @@ func getCSRFToken() (string, error) { return csrfToken, nil } -func getAdminLoginForm(username, password, csrfToken string) url.Values { +func getLoginForm(username, password, csrfToken string) url.Values { form := make(url.Values) form.Set("username", username) form.Set("password", password) @@ -7037,11 +7753,11 @@ func getJWTAPITokenFromTestServer(username, password string) (string, error) { } func getJWTWebToken(username, password string) (string, error) { - csrfToken, err := getCSRFToken() + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) if err != nil { return "", err } - form := getAdminLoginForm(username, password, csrfToken) + form := getLoginForm(username, password, csrfToken) req, _ := http.NewRequest(http.MethodPost, httpBaseURL+webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -7067,12 +7783,36 @@ func getJWTWebToken(username, password string) (string, error) { return "", errors.New("no cookie found") } -func getJWTWebTokenFromTestServer(username, password string) (string, error) { - csrfToken, err := getCSRFToken() +func getJWTWebClientTokenFromTestServerWithAddr(username, password, remoteAddr string) (string, error) { + csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath) if err != nil { return "", err } - form := getAdminLoginForm(username, password, csrfToken) + form := getLoginForm(username, password, csrfToken) + req, _ := http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + req.RemoteAddr = remoteAddr + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := executeRequest(req) + if rr.Code != http.StatusFound { + return "", fmt.Errorf("unexpected status code %v", rr) + } + cookie := strings.Split(rr.Header().Get("Set-Cookie"), ";") + if strings.HasPrefix(cookie[0], "jwt=") { + return cookie[0][4:], nil + } + return "", errors.New("no cookie found") +} + +func getJWTWebClientTokenFromTestServer(username, password string) (string, error) { + return getJWTWebClientTokenFromTestServerWithAddr(username, password, "") +} + +func getJWTWebTokenFromTestServer(username, password string) (string, error) { + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) + if err != nil { + return "", err + } + form := getLoginForm(username, password, csrfToken) req, _ := http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := executeRequest(req) @@ -7114,6 +7854,12 @@ func createTestFile(path string, size int64) error { return os.WriteFile(path, content, os.ModePerm) } +func getPostConnectScriptContent(exitCode int) []byte { + content := []byte("#!/bin/sh\n\n") + content = append(content, []byte(fmt.Sprintf("exit %v", exitCode))...) + return content +} + func getMultipartFormData(values url.Values, fileFieldName, filePath string) (bytes.Buffer, string, error) { var b bytes.Buffer w := multipart.NewWriter(&b) diff --git a/httpd/internal_test.go b/httpd/internal_test.go index baef4b1a..2f91bf94 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -353,7 +353,7 @@ func TestUpdateWebAdminInvalidClaims(t *testing.T) { Permissions: admin.Permissions, Signature: admin.GetSignature(), } - token, err := c.createTokenResponse(server.tokenAuth, tokenAudienceWeb) + token, err := c.createTokenResponse(server.tokenAuth, tokenAudienceWebAdmin) assert.NoError(t, err) form := make(url.Values) @@ -435,18 +435,18 @@ func TestCreateTokenError(t *testing.T) { req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - server.handleWebLoginPost(rr, req) + server.handleWebAdminLoginPost(rr, req) assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) // req with no content type req, _ = http.NewRequest(http.MethodPost, webLoginPath, nil) rr = httptest.NewRecorder() - server.handleWebLoginPost(rr, req) + server.handleWebAdminLoginPost(rr, req) assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) // req with no POST body req, _ = http.NewRequest(http.MethodGet, webLoginPath+"?a=a%C3%AO%GG", nil) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = httptest.NewRecorder() - server.handleWebLoginPost(rr, req) + server.handleWebAdminLoginPost(rr, req) assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) req, _ = http.NewRequest(http.MethodGet, webLoginPath+"?a=a%C3%A1%G2", nil) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -458,6 +458,51 @@ func TestCreateTokenError(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") _, err := getAdminFromPostFields(req) assert.Error(t, err) + + req, _ = http.NewRequest(http.MethodPost, webClientLoginPath+"?a=a%C3%AO%GG", bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = httptest.NewRecorder() + server.handleWebClientLoginPost(rr, req) + assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + + req, _ = http.NewRequest(http.MethodPost, webChangeClientPwdPath+"?a=a%C3%AO%GA", bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = httptest.NewRecorder() + handleWebClientChangePwdPost(rr, req) + assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String()) + + req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath+"?a=a%C3%AO%GB", bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = httptest.NewRecorder() + handleWebClientManageKeysPost(rr, req) + assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String()) + + username := "webclientuser" + user := dataprovider.User{ + Username: username, + Password: "clientpwd", + HomeDir: filepath.Join(os.TempDir(), username), + Status: 1, + Description: "test user", + } + user.Permissions = make(map[string][]string) + user.Permissions["/"] = []string{"*"} + err = dataprovider.AddUser(&user) + assert.NoError(t, err) + + rr = httptest.NewRecorder() + form = make(url.Values) + form.Set("username", user.Username) + form.Set("password", "clientpwd") + form.Set(csrfFormToken, createCSRFToken()) + req, _ = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + req.RemoteAddr = "127.0.0.1:4567" + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + server.handleWebClientLoginPost(rr, req) + assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + + err = dataprovider.DeleteUser(username) + assert.NoError(t, err) } func TestJWTTokenValidation(t *testing.T) { @@ -469,20 +514,28 @@ func TestJWTTokenValidation(t *testing.T) { assert.NoError(t, err) r := GetHTTPRouter() - fn := jwtAuthenticator(r) + fn := jwtAuthenticatorAPI(r) rr := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, userPath, nil) ctx := jwtauth.NewContext(req.Context(), token, nil) fn.ServeHTTP(rr, req.WithContext(ctx)) assert.Equal(t, http.StatusUnauthorized, rr.Code) - fn = jwtAuthenticatorWeb(r) + fn = jwtAuthenticatorWebAdmin(r) rr = httptest.NewRecorder() req, _ = http.NewRequest(http.MethodGet, webUserPath, nil) ctx = jwtauth.NewContext(req.Context(), token, nil) - fn.ServeHTTP(rr, req.WithContext(ctx)) assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webLoginPath, rr.Header().Get("Location")) + + fn = jwtAuthenticatorWebClient(r) + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + ctx = jwtauth.NewContext(req.Context(), token, nil) + fn.ServeHTTP(rr, req.WithContext(ctx)) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webClientLoginPath, rr.Header().Get("Location")) errTest := errors.New("test error") permFn := checkPerm(dataprovider.PermAdminAny) @@ -501,6 +554,15 @@ func TestJWTTokenValidation(t *testing.T) { ctx = jwtauth.NewContext(req.Context(), token, errTest) fn.ServeHTTP(rr, req.WithContext(ctx)) assert.Equal(t, http.StatusBadRequest, rr.Code) + + permClientFn := checkClientPerm(dataprovider.WebClientPubKeyChangeDisabled) + fn = permClientFn(r) + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, nil) + req.RequestURI = webChangeClientKeysPath + ctx = jwtauth.NewContext(req.Context(), token, errTest) + fn.ServeHTTP(rr, req.WithContext(ctx)) + assert.Equal(t, http.StatusBadRequest, rr.Code) } func TestAdminAllowListConnAddr(t *testing.T) { @@ -565,6 +627,7 @@ func TestCookieExpiration(t *testing.T) { claims[claimPermissionsKey] = admin.Permissions claims[jwt.SubjectKey] = admin.GetSignature() claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute) + claims[jwt.AudienceKey] = tokenAudienceAPI token, _, err = server.tokenAuth.Encode(claims) assert.NoError(t, err) req, _ = http.NewRequest(http.MethodGet, tokenPath, nil) @@ -599,6 +662,7 @@ func TestCookieExpiration(t *testing.T) { claims[claimPermissionsKey] = admin.Permissions claims[jwt.SubjectKey] = admin.GetSignature() claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute) + claims[jwt.AudienceKey] = tokenAudienceAPI token, _, err = server.tokenAuth.Encode(claims) assert.NoError(t, err) req, _ = http.NewRequest(http.MethodGet, tokenPath, nil) @@ -625,6 +689,82 @@ func TestCookieExpiration(t *testing.T) { err = dataprovider.DeleteAdmin(admin.Username) assert.NoError(t, err) + // now check client cookie expiration + username := "client" + user := dataprovider.User{ + Username: username, + Password: "clientpwd", + HomeDir: filepath.Join(os.TempDir(), username), + Status: 1, + Description: "test user", + } + user.Permissions = make(map[string][]string) + user.Permissions["/"] = []string{"*"} + + claims = make(map[string]interface{}) + claims[claimUsernameKey] = user.Username + claims[claimPermissionsKey] = user.Filters.WebClient + claims[jwt.SubjectKey] = user.GetSignature() + claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute) + claims[jwt.AudienceKey] = tokenAudienceWebClient + token, _, err = server.tokenAuth.Encode(claims) + assert.NoError(t, err) + + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + ctx = jwtauth.NewContext(req.Context(), token, nil) + server.checkCookieExpiration(rr, req.WithContext(ctx)) + cookie = rr.Header().Get("Set-Cookie") + assert.Empty(t, cookie) + // the password will be hashed and so the signature will change + err = dataprovider.AddUser(&user) + assert.NoError(t, err) + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + ctx = jwtauth.NewContext(req.Context(), token, nil) + server.checkCookieExpiration(rr, req.WithContext(ctx)) + cookie = rr.Header().Get("Set-Cookie") + assert.Empty(t, cookie) + + user, err = dataprovider.UserExists(user.Username) + assert.NoError(t, err) + user.Filters.AllowedIP = []string{"172.16.4.0/24"} + err = dataprovider.UpdateUser(&user) + assert.NoError(t, err) + + user, err = dataprovider.UserExists(user.Username) + assert.NoError(t, err) + claims = make(map[string]interface{}) + claims[claimUsernameKey] = user.Username + claims[claimPermissionsKey] = user.Filters.WebClient + claims[jwt.SubjectKey] = user.GetSignature() + claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute) + claims[jwt.AudienceKey] = tokenAudienceWebClient + token, _, err = server.tokenAuth.Encode(claims) + assert.NoError(t, err) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + req.RemoteAddr = "172.16.3.12:4567" + ctx = jwtauth.NewContext(req.Context(), token, nil) + server.checkCookieExpiration(rr, req.WithContext(ctx)) + cookie = rr.Header().Get("Set-Cookie") + assert.Empty(t, cookie) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + req.RemoteAddr = "172.16.4.12:4567" + ctx = jwtauth.NewContext(req.Context(), token, nil) + server.checkCookieExpiration(rr, req.WithContext(context.WithValue(ctx, connAddrKey, "172.16.0.1:4567"))) + cookie = rr.Header().Get("Set-Cookie") + assert.Empty(t, cookie) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + req.RemoteAddr = "172.16.4.16:4567" + ctx = jwtauth.NewContext(req.Context(), token, nil) + server.checkCookieExpiration(rr, req.WithContext(context.WithValue(ctx, connAddrKey, "172.16.4.18:4567"))) + cookie = rr.Header().Get("Set-Cookie") + assert.NotEmpty(t, cookie) + + err = dataprovider.DeleteUser(user.Username) + assert.NoError(t, err) } func TestGetURLParam(t *testing.T) { @@ -672,9 +812,13 @@ func TestCloseConnectionHandler(t *testing.T) { func TestRenderInvalidTemplate(t *testing.T) { tmpl, err := template.New("test").Parse("{{.Count}}") if assert.NoError(t, err) { - templates["no_match"] = tmpl + noMatchTmpl := "no_match" + adminTemplates[noMatchTmpl] = tmpl rw := httptest.NewRecorder() - renderTemplate(rw, "no_match", map[string]string{}) + renderAdminTemplate(rw, noMatchTmpl, map[string]string{}) + assert.Equal(t, http.StatusInternalServerError, rw.Code) + clientTemplates[noMatchTmpl] = tmpl + renderClientTemplate(rw, noMatchTmpl, map[string]string{}) assert.Equal(t, http.StatusInternalServerError, rw.Code) } } @@ -869,3 +1013,296 @@ func TestJWTTokenCleanup(t *testing.T) { assert.Eventually(t, func() bool { return !isTokenInvalidated(req) }, 1*time.Second, 200*time.Millisecond) stopJWTTokensCleanupTicker() } + +func TestWebAdminRedirect(t *testing.T) { + b := Binding{ + Address: "", + Port: 8080, + EnableWebAdmin: true, + EnableWebClient: false, + } + server := newHttpdServer(b, "../static") + server.initializeRouter() + testServer := httptest.NewServer(server.router) + defer testServer.Close() + + req, err := http.NewRequest(http.MethodGet, webRootPath, nil) + assert.NoError(t, err) + rr := httptest.NewRecorder() + testServer.Config.Handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusMovedPermanently, rr.Code, rr.Body.String()) + assert.Equal(t, webLoginPath, rr.Header().Get("Location")) + + req, err = http.NewRequest(http.MethodGet, webBasePath, nil) + assert.NoError(t, err) + rr = httptest.NewRecorder() + testServer.Config.Handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusMovedPermanently, rr.Code, rr.Body.String()) + assert.Equal(t, webLoginPath, rr.Header().Get("Location")) +} + +func TestParseRangeRequests(t *testing.T) { + // curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=24-24" + fileSize := int64(169740) + rangeHeader := "bytes=24-24" + offset, size, err := parseRangeRequest(rangeHeader[6:], fileSize) + require.NoError(t, err) + resp := fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize) + assert.Equal(t, "bytes 24-24/169740", resp) + require.Equal(t, int64(1), size) + // curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=24-" + rangeHeader = "bytes=24-" + offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize) + require.NoError(t, err) + resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize) + assert.Equal(t, "bytes 24-169739/169740", resp) + require.Equal(t, int64(169716), size) + // curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=-1" + rangeHeader = "bytes=-1" + offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize) + require.NoError(t, err) + resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize) + assert.Equal(t, "bytes 169739-169739/169740", resp) + require.Equal(t, int64(1), size) + // curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=-100" + rangeHeader = "bytes=-100" + offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize) + require.NoError(t, err) + resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize) + assert.Equal(t, "bytes 169640-169739/169740", resp) + require.Equal(t, int64(100), size) + // curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=20-30" + rangeHeader = "bytes=20-30" + offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize) + require.NoError(t, err) + resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize) + assert.Equal(t, "bytes 20-30/169740", resp) + require.Equal(t, int64(11), size) + // curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=20-169739" + rangeHeader = "bytes=20-169739" + offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize) + require.NoError(t, err) + resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize) + assert.Equal(t, "bytes 20-169739/169740", resp) + require.Equal(t, int64(169720), size) + // curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=20-169740" + rangeHeader = "bytes=20-169740" + offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize) + require.NoError(t, err) + resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize) + assert.Equal(t, "bytes 20-169739/169740", resp) + require.Equal(t, int64(169720), size) + // curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=20-169741" + rangeHeader = "bytes=20-169741" + offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize) + require.NoError(t, err) + resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize) + assert.Equal(t, "bytes 20-169739/169740", resp) + require.Equal(t, int64(169720), size) + //curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=0-" > /dev/null + rangeHeader = "bytes=0-" + offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize) + require.NoError(t, err) + resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize) + assert.Equal(t, "bytes 0-169739/169740", resp) + require.Equal(t, int64(169740), size) + // now test errors + rangeHeader = "bytes=0-a" + _, _, err = parseRangeRequest(rangeHeader[6:], fileSize) + require.Error(t, err) + rangeHeader = "bytes=" + _, _, err = parseRangeRequest(rangeHeader[6:], fileSize) + require.Error(t, err) + rangeHeader = "bytes=-" + _, _, err = parseRangeRequest(rangeHeader[6:], fileSize) + require.Error(t, err) + rangeHeader = "bytes=500-300" + _, _, err = parseRangeRequest(rangeHeader[6:], fileSize) + require.Error(t, err) + rangeHeader = "bytes=5000000" + _, _, err = parseRangeRequest(rangeHeader[6:], fileSize) + require.Error(t, err) +} + +func TestRequestHeaderErrors(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil) + req.Header.Set("If-Unmodified-Since", "not a date") + res := checkIfUnmodifiedSince(req, time.Now()) + assert.Equal(t, condNone, res) + + req, _ = http.NewRequest(http.MethodPost, webClientFilesPath, nil) + res = checkIfModifiedSince(req, time.Now()) + assert.Equal(t, condNone, res) + + req, _ = http.NewRequest(http.MethodPost, webClientFilesPath, nil) + res = checkIfRange(req, time.Now()) + assert.Equal(t, condNone, res) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + req.Header.Set("If-Modified-Since", "not a date") + res = checkIfModifiedSince(req, time.Now()) + assert.Equal(t, condNone, res) + + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + req.Header.Set("If-Range", time.Now().Format(http.TimeFormat)) + res = checkIfRange(req, time.Time{}) + assert.Equal(t, condFalse, res) + + req.Header.Set("If-Range", "invalid if range date") + res = checkIfRange(req, time.Now()) + assert.Equal(t, condFalse, res) + modTime := getFileObjectModTime(time.Time{}) + assert.Empty(t, modTime) +} + +func TestConnection(t *testing.T) { + user := dataprovider.User{ + Username: "test_httpd_user", + HomeDir: filepath.Clean(os.TempDir()), + FsConfig: vfs.Filesystem{ + Provider: vfs.GCSFilesystemProvider, + GCSConfig: vfs.GCSFsConfig{ + Bucket: "test_bucket_name", + Credentials: kms.NewPlainSecret("invalid JSON payload"), + }, + }, + } + user.Permissions = make(map[string][]string) + user.Permissions["/"] = []string{dataprovider.PermAny} + connection := &Connection{ + BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, user), + request: nil, + } + assert.Empty(t, connection.GetClientVersion()) + assert.Empty(t, connection.GetRemoteAddress()) + assert.Empty(t, connection.GetCommand()) + name := "missing file name" + _, err := connection.getFileReader(name, 0) + assert.Error(t, err) + connection.User.FsConfig.Provider = vfs.LocalFilesystemProvider + _, err = connection.getFileReader(name, 0) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestRenderDirError(t *testing.T) { + user := dataprovider.User{ + Username: "test_httpd_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, + } + + rr := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil) + renderDirContents(rr, req, connection, "missing dir") + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "text-form-error") +} + +func TestHTTPDFile(t *testing.T) { + user := dataprovider.User{ + Username: "test_httpd_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, + } + + fs, err := user.GetFilesystem("") + assert.NoError(t, err) + + name := "fileName" + p := filepath.Join(os.TempDir(), name) + err = os.WriteFile(p, []byte("contents"), os.ModePerm) + assert.NoError(t, err) + file, err := os.Open(p) + assert.NoError(t, err) + err = file.Close() + assert.NoError(t, err) + + baseTransfer := common.NewBaseTransfer(file, connection.BaseConnection, nil, p, name, common.TransferDownload, + 0, 0, 0, false, fs) + httpdFile := newHTTPDFile(baseTransfer, nil) + // the file is closed, read should fail + buf := make([]byte, 100) + _, err = httpdFile.Read(buf) + assert.Error(t, err) + err = httpdFile.Close() + assert.Error(t, err) + err = httpdFile.Close() + assert.ErrorIs(t, err, common.ErrTransferClosed) +} + +func TestChangeUserPwd(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, webChangeClientPwdPath, nil) + err := doChangeUserPassword(req, "", "", "") + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "please provide the current password and the new one two times") + } + err = doChangeUserPassword(req, "a", "b", "c") + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "the two password fields do not match") + } + err = doChangeUserPassword(req, "a", "b", "b") + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "invalid token claims") + } +} + +func TestGetFilesInvalidClaims(t *testing.T) { + server := httpdServer{} + server.initializeRouter() + + rr := httptest.NewRecorder() + user := dataprovider.User{ + Username: "", + Password: "pwd", + } + c := jwtTokenClaims{ + Username: user.Username, + Permissions: nil, + Signature: user.GetSignature(), + } + token, err := c.createTokenResponse(server.tokenAuth, tokenAudienceWebClient) + assert.NoError(t, err) + + req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil) + req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"])) + handleClientGetFiles(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") +} + +func TestManageKeysInvalidClaims(t *testing.T) { + server := httpdServer{} + server.initializeRouter() + + rr := httptest.NewRecorder() + user := dataprovider.User{ + Username: "", + Password: "pwd", + } + c := jwtTokenClaims{ + Username: user.Username, + Permissions: nil, + Signature: user.GetSignature(), + } + token, err := c.createTokenResponse(server.tokenAuth, tokenAudienceWebClient) + assert.NoError(t, err) + form := make(url.Values) + form.Set(csrfFormToken, createCSRFToken()) + form.Set("public_keys", "") + req, _ := http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode()))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"])) + handleWebClientManageKeysPost(rr, req) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") +} diff --git a/httpd/middleware.go b/httpd/middleware.go index 00f4f80a..77caee9f 100644 --- a/httpd/middleware.go +++ b/httpd/middleware.go @@ -15,7 +15,10 @@ import ( "github.com/drakkan/sftpgo/utils" ) -var connAddrKey = &contextKey{"connection address"} +var ( + connAddrKey = &contextKey{"connection address"} + errInvalidToken = errors.New("invalid JWT token") +) type contextKey struct { name string @@ -32,30 +35,60 @@ func saveConnectionAddress(next http.Handler) http.Handler { }) } -func jwtAuthenticator(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token, _, err := jwtauth.FromContext(r.Context()) +func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudience) error { + token, _, err := jwtauth.FromContext(r.Context()) - if err != nil || token == nil { - logger.Debug(logSender, "", "error getting jwt token: %v", err) - sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } + var redirectPath string + if audience == tokenAudienceWebAdmin { + redirectPath = webLoginPath + } else { + redirectPath = webClientLoginPath + } - err = jwt.Validate(token) - if err != nil { - logger.Debug(logSender, "", "error validating jwt token: %v", err) + if err != nil || token == nil { + logger.Debug(logSender, "", "error getting jwt token: %v", err) + if audience == tokenAudienceAPI { sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return + } else { + http.Redirect(w, r, redirectPath, http.StatusFound) } - if !utils.IsStringInSlice(tokenAudienceAPI, token.Audience()) { - logger.Debug(logSender, "", "the token audience is not valid for API usage") + return errInvalidToken + } + + err = jwt.Validate(token) + if err != nil { + logger.Debug(logSender, "", "error validating jwt token: %v", err) + if audience == tokenAudienceAPI { + sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + } else { + http.Redirect(w, r, redirectPath, http.StatusFound) + } + return errInvalidToken + } + if !utils.IsStringInSlice(audience, token.Audience()) { + logger.Debug(logSender, "", "the token is not valid for audience %#v", audience) + if audience == tokenAudienceAPI { sendAPIResponse(w, r, nil, "Your token audience is not valid", http.StatusUnauthorized) - return + } else { + http.Redirect(w, r, redirectPath, http.StatusFound) } - if isTokenInvalidated(r) { - logger.Debug(logSender, "", "the token has been invalidated") + return errInvalidToken + } + if isTokenInvalidated(r) { + logger.Debug(logSender, "", "the token has been invalidated") + if audience == tokenAudienceAPI { sendAPIResponse(w, r, nil, "Your token is no longer valid", http.StatusUnauthorized) + } else { + http.Redirect(w, r, redirectPath, http.StatusFound) + } + return errInvalidToken + } + return nil +} + +func jwtAuthenticatorAPI(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := validateJWTToken(w, r, tokenAudienceAPI); err != nil { return } @@ -64,30 +97,9 @@ func jwtAuthenticator(next http.Handler) http.Handler { }) } -func jwtAuthenticatorWeb(next http.Handler) http.Handler { +func jwtAuthenticatorWebAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token, _, err := jwtauth.FromContext(r.Context()) - - if err != nil || token == nil { - logger.Debug(logSender, "", "error getting web jwt token: %v", err) - http.Redirect(w, r, webLoginPath, http.StatusFound) - return - } - - err = jwt.Validate(token) - if err != nil { - logger.Debug(logSender, "", "error validating web jwt token: %v", err) - http.Redirect(w, r, webLoginPath, http.StatusFound) - return - } - if !utils.IsStringInSlice(tokenAudienceWeb, token.Audience()) { - logger.Debug(logSender, "", "the token audience is not valid for Web usage") - http.Redirect(w, r, webLoginPath, http.StatusFound) - return - } - if isTokenInvalidated(r) { - logger.Debug(logSender, "", "the token has been invalidated") - http.Redirect(w, r, webLoginPath, http.StatusFound) + if err := validateJWTToken(w, r, tokenAudienceWebAdmin); err != nil { return } @@ -96,12 +108,44 @@ func jwtAuthenticatorWeb(next http.Handler) http.Handler { }) } +func jwtAuthenticatorWebClient(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := validateJWTToken(w, r, tokenAudienceWebClient); err != nil { + return + } + + // Token is authenticated, pass it through + next.ServeHTTP(w, r) + }) +} + +func checkClientPerm(perm string) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, claims, err := jwtauth.FromContext(r.Context()) + if err != nil { + renderClientBadRequestPage(w, r, err) + return + } + tokenClaims := jwtTokenClaims{} + tokenClaims.Decode(claims) + // for web client perms are negated and not granted + if tokenClaims.hasPerm(perm) { + renderClientForbiddenPage(w, r, "You don't have permission for this action") + return + } + + next.ServeHTTP(w, r) + }) + } +} + func checkPerm(perm string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, claims, err := jwtauth.FromContext(r.Context()) if err != nil { - if isWebAdminRequest(r) { + if isWebRequest(r) { renderBadRequestPage(w, r, err) } else { sendAPIResponse(w, r, err, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) @@ -112,7 +156,7 @@ func checkPerm(perm string) func(next http.Handler) http.Handler { tokenClaims.Decode(claims) if !tokenClaims.hasPerm(perm) { - if isWebAdminRequest(r) { + if isWebRequest(r) { renderForbiddenPage(w, r, "You don't have permission for this action") } else { sendAPIResponse(w, r, nil, http.StatusText(http.StatusForbidden), http.StatusForbidden) diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 013142b8..659e697e 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -1375,11 +1375,20 @@ components: - SSH - FTP - DAV + - HTTP description: | Protocols: * `SSH` - includes both SFTP and SSH commands * `FTP` - plain FTP and FTPES/FTPS * `DAV` - WebDAV over HTTP/HTTPS + * `HTTP` - WebClient + WebClientOptions: + type: string + enum: + - publickey-change-disabled + description: | + Options: + * `publickey-change-disabled` - changing SSH public keys is not allowed PatternsFilter: type: object properties: @@ -1492,7 +1501,12 @@ components: type: boolean example: false description: Disable checks for existence and automatic creation of home directory and virtual folders. SFTPGo requires that the user's home directory, virtual folder root, and intermediate paths to virtual folders exist to work properly. If you already know that the required directories exist, disabling these checks will speed up login. You could, for example, disable these checks after the first login - description: Additional user restrictions + web_client: + type: array + items: + $ref: '#/components/schemas/WebClientOptions' + description: WebClient related configuration options + description: Additional user options Secret: type: object properties: diff --git a/httpd/server.go b/httpd/server.go index f1b26f5f..53b7aac8 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -14,6 +14,7 @@ import ( "github.com/go-chi/jwtauth/v5" "github.com/go-chi/render" "github.com/lestrrat-go/jwx/jwa" + "github.com/rs/xid" "github.com/drakkan/sftpgo/common" "github.com/drakkan/sftpgo/dataprovider" @@ -28,27 +29,32 @@ type httpdServer struct { binding Binding staticFilesPath string enableWebAdmin bool + enableWebClient bool router *chi.Mux tokenAuth *jwtauth.JWTAuth } -func newHttpdServer(b Binding, staticFilesPath string, enableWebAdmin bool) *httpdServer { +func newHttpdServer(b Binding, staticFilesPath string) *httpdServer { return &httpdServer{ binding: b, staticFilesPath: staticFilesPath, - enableWebAdmin: enableWebAdmin && b.EnableWebAdmin, + enableWebAdmin: b.EnableWebAdmin, + enableWebClient: b.EnableWebClient, } } func (s *httpdServer) listenAndServe() error { s.initializeRouter() httpServer := &http.Server{ - Handler: s.router, - ReadTimeout: 60 * time.Second, - WriteTimeout: 60 * time.Second, - IdleTimeout: 120 * time.Second, - MaxHeaderBytes: 1 << 16, // 64KB - ErrorLog: log.New(&logger.StdLoggerWrapper{Sender: logSender}, "", 0), + Handler: s.router, + ReadHeaderTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 16, // 64KB + ErrorLog: log.New(&logger.StdLoggerWrapper{Sender: logSender}, "", 0), + } + if !s.binding.EnableWebClient { + httpServer.ReadTimeout = 60 * time.Second + httpServer.WriteTimeout = 90 * time.Second } if certMgr != nil && s.binding.EnableHTTPS { config := &tls.Config{ @@ -104,7 +110,81 @@ func (s *httpdServer) refreshCookie(next http.Handler) http.Handler { }) } -func (s *httpdServer) handleWebLoginPost(w http.ResponseWriter, r *http.Request) { +func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + common.Connections.AddNetworkConnection() + defer common.Connections.RemoveNetworkConnection() + + if err := r.ParseForm(); err != nil { + renderClientLoginPage(w, err.Error()) + return + } + username := r.Form.Get("username") + password := r.Form.Get("password") + if username == "" || password == "" { + renderClientLoginPage(w, "Invalid credentials") + return + } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderClientLoginPage(w, err.Error()) + return + } + + ipAddr := utils.GetIPFromRemoteAddress(r.RemoteAddr) + if !common.Connections.IsNewConnectionAllowed() { + logger.Log(logger.LevelDebug, common.ProtocolHTTP, "", "connection refused, configured limit reached") + renderClientLoginPage(w, "configured connections limit reached") + return + } + if common.IsBanned(ipAddr) { + renderClientLoginPage(w, "your IP address is banned") + return + } + + if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolHTTP); err != nil { + renderClientLoginPage(w, fmt.Sprintf("access denied by post connect hook: %v", err)) + return + } + + user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, common.ProtocolHTTP) + if err != nil { + updateLoginMetrics(&user, ipAddr, err) + renderClientLoginPage(w, dataprovider.ErrInvalidCredentials.Error()) + return + } + connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String()) + if err := checkWebClientUser(&user, r, connectionID); err != nil { + updateLoginMetrics(&user, ipAddr, err) + renderClientLoginPage(w, err.Error()) + return + } + + defer user.CloseFs() //nolint:errcheck + err = user.CheckFsRoot(connectionID) + if err != nil { + logger.Warn(logSender, connectionID, "unable to check fs root: %v", err) + updateLoginMetrics(&user, ipAddr, err) + renderClientLoginPage(w, err.Error()) + return + } + + c := jwtTokenClaims{ + Username: user.Username, + Permissions: user.Filters.WebClient, + Signature: user.GetSignature(), + } + + err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebClient) + if err != nil { + updateLoginMetrics(&user, ipAddr, err) + renderLoginPage(w, err.Error()) + return + } + updateLoginMetrics(&user, ipAddr, err) + http.Redirect(w, r, webClientFilesPath, http.StatusFound) +} + +func (s *httpdServer) handleWebAdminLoginPost(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) if err := r.ParseForm(); err != nil { renderLoginPage(w, err.Error()) @@ -139,7 +219,7 @@ func (s *httpdServer) handleWebLoginPost(w http.ResponseWriter, r *http.Request) Signature: admin.GetSignature(), } - err = c.createAndSetCookie(w, r, s.tokenAuth) + err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebAdmin) if err != nil { renderLoginPage(w, err.Error()) return @@ -209,6 +289,32 @@ func (s *httpdServer) checkCookieExpiration(w http.ResponseWriter, r *http.Reque if time.Until(token.Expiration()) > tokenRefreshMin { return } + if utils.IsStringInSlice(tokenAudienceWebClient, token.Audience()) { + s.refreshClientToken(w, r, tokenClaims) + } else { + s.refreshAdminToken(w, r, tokenClaims) + } +} + +func (s *httpdServer) refreshClientToken(w http.ResponseWriter, r *http.Request, tokenClaims jwtTokenClaims) { + user, err := dataprovider.UserExists(tokenClaims.Username) + if err != nil { + return + } + if user.GetSignature() != tokenClaims.Signature { + logger.Debug(logSender, "", "signature mismatch for user %#v, unable to refresh cookie", user.Username) + return + } + if err := checkWebClientUser(&user, r, xid.New().String()); err != nil { + logger.Debug(logSender, "", "unable to refresh cookie for user %#v: %v", user.Username, err) + return + } + + logger.Debug(logSender, "", "cookie refreshed for user %#v", user.Username) + tokenClaims.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebClient) //nolint:errcheck +} + +func (s *httpdServer) refreshAdminToken(w http.ResponseWriter, r *http.Request, tokenClaims jwtTokenClaims) { admin, err := dataprovider.AdminExists(tokenClaims.Username) if err != nil { return @@ -235,7 +341,7 @@ func (s *httpdServer) checkCookieExpiration(w http.ResponseWriter, r *http.Reque } } logger.Debug(logSender, "", "cookie refreshed for admin %#v", admin.Username) - tokenClaims.createAndSetCookie(w, r, s.tokenAuth) //nolint:errcheck + tokenClaims.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebAdmin) //nolint:errcheck } func (s *httpdServer) updateContextFromCookie(r *http.Request) *http.Request { @@ -274,8 +380,12 @@ func (s *httpdServer) initializeRouter() { router.Use(middleware.Recoverer) router.NotFound(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if s.enableWebAdmin && isWebAdminRequest(r) { + if (s.enableWebAdmin || s.enableWebClient) && isWebRequest(r) { r = s.updateContextFromCookie(r) + if s.enableWebClient && (isWebClientRequest(r) || !s.enableWebAdmin) { + renderClientNotFoundPage(w, r, nil) + return + } renderNotFoundPage(w, r, nil) return } @@ -286,7 +396,7 @@ func (s *httpdServer) initializeRouter() { router.Group(func(router chi.Router) { router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromHeader)) - router.Use(jwtAuthenticator) + router.Use(jwtAuthenticatorAPI) router.Get(versionPath, func(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, version.Get()) @@ -336,21 +446,58 @@ func (s *httpdServer) initializeRouter() { router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Delete(adminPath+"/{username}", deleteAdmin) }) - if s.enableWebAdmin { - router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, webLoginPath, http.StatusMovedPermanently) + if s.enableWebAdmin || s.enableWebClient { + router.Group(func(router chi.Router) { + router.Use(compressor.Handler) + fileServer(router, webStaticFilesPath, http.Dir(s.staticFilesPath)) }) + if s.enableWebClient { + router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, webClientLoginPath, http.StatusMovedPermanently) + }) + router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, webClientLoginPath, http.StatusMovedPermanently) + }) + } else { + router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, webLoginPath, http.StatusMovedPermanently) + }) + router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, webLoginPath, http.StatusMovedPermanently) + }) + } + } - router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, webLoginPath, http.StatusMovedPermanently) + if s.enableWebClient { + router.Get(webBaseClientPath, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, webClientLoginPath, http.StatusMovedPermanently) }) - - router.Get(webLoginPath, handleWebLogin) - router.Post(webLoginPath, s.handleWebLoginPost) + router.Get(webClientLoginPath, handleClientWebLogin) + router.Post(webClientLoginPath, s.handleWebClientLoginPost) router.Group(func(router chi.Router) { router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie)) - router.Use(jwtAuthenticatorWeb) + router.Use(jwtAuthenticatorWebClient) + + router.Get(webClientLogoutPath, handleWebClientLogout) + router.With(s.refreshCookie).Get(webClientFilesPath, handleClientGetFiles) + router.With(s.refreshCookie).Get(webClientCredentialsPath, handleClientGetCredentials) + router.Post(webChangeClientPwdPath, handleWebClientChangePwdPost) + router.With(checkClientPerm(dataprovider.WebClientPubKeyChangeDisabled)). + Post(webChangeClientKeysPath, handleWebClientManageKeysPost) + }) + } + + if s.enableWebAdmin { + router.Get(webBaseAdminPath, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, webLoginPath, http.StatusMovedPermanently) + }) + router.Get(webLoginPath, handleWebLogin) + router.Post(webLoginPath, s.handleWebAdminLoginPost) + + router.Group(func(router chi.Router) { + router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie)) + router.Use(jwtAuthenticatorWebAdmin) router.Get(webLogoutPath, handleWebLogout) router.With(s.refreshCookie).Get(webChangeAdminPwdPath, handleWebAdminChangePwd) @@ -405,11 +552,6 @@ func (s *httpdServer) initializeRouter() { Get(webTemplateFolder, handleWebTemplateFolderGet) router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webTemplateFolder, handleWebTemplateFolderPost) }) - - router.Group(func(router chi.Router) { - router.Use(compressor.Handler) - fileServer(router, webStaticFilesPath, http.Dir(s.staticFilesPath)) - }) } }) } diff --git a/httpd/web.go b/httpd/web.go index 5e4c87f6..1385b886 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -1,183 +1,25 @@ package httpd import ( - "errors" - "fmt" - "html/template" - "io" - "net/http" - "net/url" "path" - "path/filepath" - "strconv" "strings" - "time" - "github.com/go-chi/render" - - "github.com/drakkan/sftpgo/common" - "github.com/drakkan/sftpgo/dataprovider" - "github.com/drakkan/sftpgo/kms" "github.com/drakkan/sftpgo/utils" - "github.com/drakkan/sftpgo/version" - "github.com/drakkan/sftpgo/vfs" -) - -type userPageMode int - -const ( - userPageModeAdd userPageMode = iota + 1 - userPageModeUpdate - userPageModeTemplate -) - -type folderPageMode int - -const ( - folderPageModeAdd folderPageMode = iota + 1 - folderPageModeUpdate - folderPageModeTemplate ) const ( - templateBase = "base.html" - templateFsConfig = "fsconfig.html" - templateUsers = "users.html" - templateUser = "user.html" - templateAdmins = "admins.html" - templateAdmin = "admin.html" - templateConnections = "connections.html" - templateFolders = "folders.html" - templateFolder = "folder.html" - templateMessage = "message.html" - templateStatus = "status.html" - templateLogin = "login.html" - templateChangePwd = "changepwd.html" - templateMaintenance = "maintenance.html" - pageUsersTitle = "Users" - pageAdminsTitle = "Admins" - pageConnectionsTitle = "Connections" - pageStatusTitle = "Status" - pageFoldersTitle = "Folders" - pageChangePwdTitle = "Change password" - pageMaintenanceTitle = "Maintenance" - page400Title = "Bad request" - page403Title = "Forbidden" - page404Title = "Not found" - page404Body = "The page you are looking for does not exist." - page500Title = "Internal Server Error" - page500Body = "The server is unable to fulfill your request." - defaultQueryLimit = 500 - webDateTimeFormat = "2006-01-02 15:04:05" // YYYY-MM-DD HH:MM:SS - redactedSecret = "[**redacted**]" - csrfFormToken = "_form_token" - csrfHeaderToken = "X-CSRF-TOKEN" + page400Title = "Bad request" + page403Title = "Forbidden" + page404Title = "Not found" + page404Body = "The page you are looking for does not exist." + page500Title = "Internal Server Error" + page500Body = "The server is unable to fulfill your request." + webDateTimeFormat = "2006-01-02 15:04:05" // YYYY-MM-DD HH:MM:SS + redactedSecret = "[**redacted**]" + csrfFormToken = "_form_token" + csrfHeaderToken = "X-CSRF-TOKEN" ) -var ( - templates = make(map[string]*template.Template) -) - -type basePage struct { - Title string - CurrentURL string - UsersURL string - UserURL string - UserTemplateURL string - AdminsURL string - AdminURL string - QuotaScanURL string - ConnectionsURL string - FoldersURL string - FolderURL string - FolderTemplateURL string - LogoutURL string - ChangeAdminPwdURL string - FolderQuotaScanURL string - StatusURL string - MaintenanceURL string - StaticURL string - UsersTitle string - AdminsTitle string - ConnectionsTitle string - FoldersTitle string - StatusTitle string - MaintenanceTitle string - Version string - CSRFToken string - LoggedAdmin *dataprovider.Admin -} - -type usersPage struct { - basePage - Users []dataprovider.User -} - -type adminsPage struct { - basePage - Admins []dataprovider.Admin -} - -type foldersPage struct { - basePage - Folders []vfs.BaseVirtualFolder -} - -type connectionsPage struct { - basePage - Connections []*common.ConnectionStatus -} - -type statusPage struct { - basePage - Status ServicesStatus -} - -type userPage struct { - basePage - User *dataprovider.User - RootPerms []string - Error string - ValidPerms []string - ValidLoginMethods []string - ValidProtocols []string - RootDirPerms []string - RedactedSecret string - Mode userPageMode -} - -type adminPage struct { - basePage - Admin *dataprovider.Admin - Error string - IsAdd bool -} - -type changePwdPage struct { - basePage - Error string -} - -type maintenancePage struct { - basePage - BackupPath string - RestorePath string - Error string -} - -type folderPage struct { - basePage - Folder vfs.BaseVirtualFolder - Error string - Mode folderPageMode -} - -type messagePage struct { - basePage - Error string - Success string -} - type loginPage struct { CurrentURL string Version string @@ -186,364 +28,6 @@ type loginPage struct { StaticURL string } -type userTemplateFields struct { - Username string - Password string - PublicKey string -} - -func loadTemplates(templatesPath string) { - usersPaths := []string{ - filepath.Join(templatesPath, templateBase), - filepath.Join(templatesPath, templateUsers), - } - userPaths := []string{ - filepath.Join(templatesPath, templateBase), - filepath.Join(templatesPath, templateFsConfig), - filepath.Join(templatesPath, templateUser), - } - adminsPaths := []string{ - filepath.Join(templatesPath, templateBase), - filepath.Join(templatesPath, templateAdmins), - } - adminPaths := []string{ - filepath.Join(templatesPath, templateBase), - filepath.Join(templatesPath, templateAdmin), - } - changePwdPaths := []string{ - filepath.Join(templatesPath, templateBase), - filepath.Join(templatesPath, templateChangePwd), - } - connectionsPaths := []string{ - filepath.Join(templatesPath, templateBase), - filepath.Join(templatesPath, templateConnections), - } - messagePath := []string{ - filepath.Join(templatesPath, templateBase), - filepath.Join(templatesPath, templateMessage), - } - foldersPath := []string{ - filepath.Join(templatesPath, templateBase), - filepath.Join(templatesPath, templateFolders), - } - folderPath := []string{ - filepath.Join(templatesPath, templateBase), - filepath.Join(templatesPath, templateFsConfig), - filepath.Join(templatesPath, templateFolder), - } - statusPath := []string{ - filepath.Join(templatesPath, templateBase), - filepath.Join(templatesPath, templateStatus), - } - loginPath := []string{ - filepath.Join(templatesPath, templateLogin), - } - maintenancePath := []string{ - filepath.Join(templatesPath, templateBase), - filepath.Join(templatesPath, templateMaintenance), - } - usersTmpl := utils.LoadTemplate(template.ParseFiles(usersPaths...)) - userTmpl := utils.LoadTemplate(template.ParseFiles(userPaths...)) - adminsTmpl := utils.LoadTemplate(template.ParseFiles(adminsPaths...)) - adminTmpl := utils.LoadTemplate(template.ParseFiles(adminPaths...)) - connectionsTmpl := utils.LoadTemplate(template.ParseFiles(connectionsPaths...)) - messageTmpl := utils.LoadTemplate(template.ParseFiles(messagePath...)) - foldersTmpl := utils.LoadTemplate(template.ParseFiles(foldersPath...)) - folderTmpl := utils.LoadTemplate(template.ParseFiles(folderPath...)) - statusTmpl := utils.LoadTemplate(template.ParseFiles(statusPath...)) - loginTmpl := utils.LoadTemplate(template.ParseFiles(loginPath...)) - changePwdTmpl := utils.LoadTemplate(template.ParseFiles(changePwdPaths...)) - maintenanceTmpl := utils.LoadTemplate(template.ParseFiles(maintenancePath...)) - - templates[templateUsers] = usersTmpl - templates[templateUser] = userTmpl - templates[templateAdmins] = adminsTmpl - templates[templateAdmin] = adminTmpl - templates[templateConnections] = connectionsTmpl - templates[templateMessage] = messageTmpl - templates[templateFolders] = foldersTmpl - templates[templateFolder] = folderTmpl - templates[templateStatus] = statusTmpl - templates[templateLogin] = loginTmpl - templates[templateChangePwd] = changePwdTmpl - templates[templateMaintenance] = maintenanceTmpl -} - -func getBasePageData(title, currentURL string, r *http.Request) basePage { - var csrfToken string - if currentURL != "" { - csrfToken = createCSRFToken() - } - return basePage{ - Title: title, - CurrentURL: currentURL, - UsersURL: webUsersPath, - UserURL: webUserPath, - UserTemplateURL: webTemplateUser, - AdminsURL: webAdminsPath, - AdminURL: webAdminPath, - FoldersURL: webFoldersPath, - FolderURL: webFolderPath, - FolderTemplateURL: webTemplateFolder, - LogoutURL: webLogoutPath, - ChangeAdminPwdURL: webChangeAdminPwdPath, - QuotaScanURL: webQuotaScanPath, - ConnectionsURL: webConnectionsPath, - StatusURL: webStatusPath, - FolderQuotaScanURL: webScanVFolderPath, - MaintenanceURL: webMaintenancePath, - StaticURL: webStaticFilesPath, - UsersTitle: pageUsersTitle, - AdminsTitle: pageAdminsTitle, - ConnectionsTitle: pageConnectionsTitle, - FoldersTitle: pageFoldersTitle, - StatusTitle: pageStatusTitle, - MaintenanceTitle: pageMaintenanceTitle, - Version: version.GetAsString(), - LoggedAdmin: getAdminFromToken(r), - CSRFToken: csrfToken, - } -} - -func renderTemplate(w http.ResponseWriter, tmplName string, data interface{}) { - err := templates[tmplName].ExecuteTemplate(w, tmplName, data) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -func renderMessagePage(w http.ResponseWriter, r *http.Request, title, body string, statusCode int, err error, message string) { - var errorString string - if body != "" { - errorString = body + " " - } - if err != nil { - errorString += err.Error() - } - data := messagePage{ - basePage: getBasePageData(title, "", r), - Error: errorString, - Success: message, - } - w.WriteHeader(statusCode) - renderTemplate(w, templateMessage, data) -} - -func renderInternalServerErrorPage(w http.ResponseWriter, r *http.Request, err error) { - renderMessagePage(w, r, page500Title, page500Body, http.StatusInternalServerError, err, "") -} - -func renderBadRequestPage(w http.ResponseWriter, r *http.Request, err error) { - renderMessagePage(w, r, page400Title, "", http.StatusBadRequest, err, "") -} - -func renderForbiddenPage(w http.ResponseWriter, r *http.Request, body string) { - renderMessagePage(w, r, page403Title, "", http.StatusForbidden, nil, body) -} - -func renderNotFoundPage(w http.ResponseWriter, r *http.Request, err error) { - renderMessagePage(w, r, page404Title, page404Body, http.StatusNotFound, err, "") -} - -func renderChangePwdPage(w http.ResponseWriter, r *http.Request, error string) { - data := changePwdPage{ - basePage: getBasePageData(pageChangePwdTitle, webChangeAdminPwdPath, r), - Error: error, - } - - renderTemplate(w, templateChangePwd, data) -} - -func renderMaintenancePage(w http.ResponseWriter, r *http.Request, error string) { - data := maintenancePage{ - basePage: getBasePageData(pageMaintenanceTitle, webMaintenancePath, r), - BackupPath: webBackupPath, - RestorePath: webRestorePath, - Error: error, - } - - renderTemplate(w, templateMaintenance, data) -} - -func renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Request, admin *dataprovider.Admin, - error string, isAdd bool) { - currentURL := webAdminPath - if !isAdd { - currentURL = fmt.Sprintf("%v/%v", webAdminPath, url.PathEscape(admin.Username)) - } - data := adminPage{ - basePage: getBasePageData("Add a new user", currentURL, r), - Admin: admin, - Error: error, - IsAdd: isAdd, - } - - renderTemplate(w, templateAdmin, data) -} - -func renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.User, mode userPageMode, error string) { - user.SetEmptySecretsIfNil() - var title, currentURL string - switch mode { - case userPageModeAdd: - title = "Add a new user" - currentURL = webUserPath - case userPageModeUpdate: - title = "Update user" - currentURL = fmt.Sprintf("%v/%v", webUserPath, url.PathEscape(user.Username)) - case userPageModeTemplate: - title = "User template" - currentURL = webTemplateUser - } - if user.Password != "" && user.IsPasswordHashed() && mode == userPageModeUpdate { - user.Password = redactedSecret - } - user.FsConfig.RedactedSecret = redactedSecret - data := userPage{ - basePage: getBasePageData(title, currentURL, r), - Mode: mode, - Error: error, - User: user, - ValidPerms: dataprovider.ValidPerms, - ValidLoginMethods: dataprovider.ValidLoginMethods, - ValidProtocols: dataprovider.ValidProtocols, - RootDirPerms: user.GetPermissionsForPath("/"), - } - renderTemplate(w, templateUser, data) -} - -func renderFolderPage(w http.ResponseWriter, r *http.Request, folder vfs.BaseVirtualFolder, mode folderPageMode, error string) { - var title, currentURL string - switch mode { - case folderPageModeAdd: - title = "Add a new folder" - currentURL = webFolderPath - case folderPageModeUpdate: - title = "Update folder" - currentURL = fmt.Sprintf("%v/%v", webFolderPath, url.PathEscape(folder.Name)) - case folderPageModeTemplate: - title = "Folder template" - currentURL = webTemplateFolder - } - folder.FsConfig.RedactedSecret = redactedSecret - folder.FsConfig.SetEmptySecretsIfNil() - - data := folderPage{ - basePage: getBasePageData(title, currentURL, r), - Error: error, - Folder: folder, - Mode: mode, - } - renderTemplate(w, templateFolder, data) -} - -func getFoldersForTemplate(r *http.Request) []string { - var res []string - formValue := r.Form.Get("folders") - folders := make(map[string]bool) - for _, name := range getSliceFromDelimitedValues(formValue, "\n") { - if _, ok := folders[name]; ok { - continue - } - folders[name] = true - res = append(res, name) - } - return res -} - -func getUsersForTemplate(r *http.Request) []userTemplateFields { - var res []userTemplateFields - formValue := r.Form.Get("users") - users := make(map[string]bool) - for _, cleaned := range getSliceFromDelimitedValues(formValue, "\n") { - if strings.Contains(cleaned, "::") { - mapping := strings.Split(cleaned, "::") - if len(mapping) > 1 { - username := strings.TrimSpace(mapping[0]) - password := strings.TrimSpace(mapping[1]) - var publicKey string - if len(mapping) > 2 { - publicKey = strings.TrimSpace(mapping[2]) - } - if username == "" || (password == "" && publicKey == "") { - continue - } - if _, ok := users[username]; ok { - continue - } - - users[username] = true - res = append(res, userTemplateFields{ - Username: username, - Password: password, - PublicKey: publicKey, - }) - } - } - } - return res -} - -func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder { - var virtualFolders []vfs.VirtualFolder - formValue := r.Form.Get("virtual_folders") - for _, cleaned := range getSliceFromDelimitedValues(formValue, "\n") { - if strings.Contains(cleaned, "::") { - mapping := strings.Split(cleaned, "::") - if len(mapping) > 1 { - vfolder := vfs.VirtualFolder{ - BaseVirtualFolder: vfs.BaseVirtualFolder{ - Name: strings.TrimSpace(mapping[1]), - }, - VirtualPath: strings.TrimSpace(mapping[0]), - QuotaFiles: -1, - QuotaSize: -1, - } - if len(mapping) > 2 { - quotaFiles, err := strconv.Atoi(strings.TrimSpace(mapping[2])) - if err == nil { - vfolder.QuotaFiles = quotaFiles - } - } - if len(mapping) > 3 { - quotaSize, err := strconv.ParseInt(strings.TrimSpace(mapping[3]), 10, 64) - if err == nil { - vfolder.QuotaSize = quotaSize - } - } - virtualFolders = append(virtualFolders, vfolder) - } - } - } - return virtualFolders -} - -func getUserPermissionsFromPostFields(r *http.Request) map[string][]string { - permissions := make(map[string][]string) - permissions["/"] = r.Form["permissions"] - subDirsPermsValue := r.Form.Get("sub_dirs_permissions") - for _, cleaned := range getSliceFromDelimitedValues(subDirsPermsValue, "\n") { - if strings.Contains(cleaned, "::") { - dirPerms := strings.Split(cleaned, "::") - if len(dirPerms) > 1 { - dir := dirPerms[0] - dir = strings.TrimSpace(dir) - perms := []string{} - for _, p := range strings.Split(dirPerms[1], ",") { - cleanedPerm := strings.TrimSpace(p) - if cleanedPerm != "" { - perms = append(perms, cleanedPerm) - } - } - if dir != "" { - permissions[dir] = perms - } - } - } - } - return permissions -} - func getSliceFromDelimitedValues(values, delimiter string) []string { result := []string{} for _, v := range strings.Split(values, delimiter) { @@ -583,1032 +67,3 @@ func getListFromPostFields(value string) map[string][]string { } return result } - -func getFilePatternsFromPostField(valueAllowed, valuesDenied string) []dataprovider.PatternsFilter { - var result []dataprovider.PatternsFilter - allowedPatterns := getListFromPostFields(valueAllowed) - deniedPatterns := getListFromPostFields(valuesDenied) - - for dirAllowed, allowPatterns := range allowedPatterns { - filter := dataprovider.PatternsFilter{ - Path: dirAllowed, - AllowedPatterns: allowPatterns, - } - for dirDenied, denPatterns := range deniedPatterns { - if dirAllowed == dirDenied { - filter.DeniedPatterns = denPatterns - break - } - } - result = append(result, filter) - } - for dirDenied, denPatterns := range deniedPatterns { - found := false - for _, res := range result { - if res.Path == dirDenied { - found = true - break - } - } - if !found { - result = append(result, dataprovider.PatternsFilter{ - Path: dirDenied, - DeniedPatterns: denPatterns, - }) - } - } - return result -} - -func getFileExtensionsFromPostField(valueAllowed, valuesDenied string) []dataprovider.ExtensionsFilter { - var result []dataprovider.ExtensionsFilter - allowedExtensions := getListFromPostFields(valueAllowed) - deniedExtensions := getListFromPostFields(valuesDenied) - - for dirAllowed, allowedExts := range allowedExtensions { - filter := dataprovider.ExtensionsFilter{ - Path: dirAllowed, - AllowedExtensions: allowedExts, - } - for dirDenied, deniedExts := range deniedExtensions { - if dirAllowed == dirDenied { - filter.DeniedExtensions = deniedExts - break - } - } - result = append(result, filter) - } - for dirDenied, deniedExts := range deniedExtensions { - found := false - for _, res := range result { - if res.Path == dirDenied { - found = true - break - } - } - if !found { - result = append(result, dataprovider.ExtensionsFilter{ - Path: dirDenied, - DeniedExtensions: deniedExts, - }) - } - } - return result -} - -func getFiltersFromUserPostFields(r *http.Request) dataprovider.UserFilters { - var filters dataprovider.UserFilters - filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",") - filters.DeniedIP = getSliceFromDelimitedValues(r.Form.Get("denied_ip"), ",") - filters.DeniedLoginMethods = r.Form["ssh_login_methods"] - filters.DeniedProtocols = r.Form["denied_protocols"] - filters.FileExtensions = getFileExtensionsFromPostField(r.Form.Get("allowed_extensions"), r.Form.Get("denied_extensions")) - filters.FilePatterns = getFilePatternsFromPostField(r.Form.Get("allowed_patterns"), r.Form.Get("denied_patterns")) - filters.TLSUsername = dataprovider.TLSUsername(r.Form.Get("tls_username")) - hooks := r.Form["hooks"] - if utils.IsStringInSlice("external_auth_disabled", hooks) { - filters.Hooks.ExternalAuthDisabled = true - } - if utils.IsStringInSlice("pre_login_disabled", hooks) { - filters.Hooks.PreLoginDisabled = true - } - if utils.IsStringInSlice("check_password_disabled", hooks) { - filters.Hooks.CheckPasswordDisabled = true - } - filters.DisableFsChecks = len(r.Form.Get("disable_fs_checks")) > 0 - return filters -} - -func getSecretFromFormField(r *http.Request, field string) *kms.Secret { - secret := kms.NewPlainSecret(r.Form.Get(field)) - if strings.TrimSpace(secret.GetPayload()) == redactedSecret { - secret.SetStatus(kms.SecretStatusRedacted) - } - if strings.TrimSpace(secret.GetPayload()) == "" { - secret.SetStatus("") - } - return secret -} - -func getS3Config(r *http.Request) (vfs.S3FsConfig, error) { - var err error - config := vfs.S3FsConfig{} - config.Bucket = r.Form.Get("s3_bucket") - config.Region = r.Form.Get("s3_region") - config.AccessKey = r.Form.Get("s3_access_key") - config.AccessSecret = getSecretFromFormField(r, "s3_access_secret") - config.Endpoint = r.Form.Get("s3_endpoint") - config.StorageClass = r.Form.Get("s3_storage_class") - config.KeyPrefix = r.Form.Get("s3_key_prefix") - config.UploadPartSize, err = strconv.ParseInt(r.Form.Get("s3_upload_part_size"), 10, 64) - if err != nil { - return config, err - } - config.UploadConcurrency, err = strconv.Atoi(r.Form.Get("s3_upload_concurrency")) - return config, err -} - -func getGCSConfig(r *http.Request) (vfs.GCSFsConfig, error) { - var err error - config := vfs.GCSFsConfig{} - - config.Bucket = r.Form.Get("gcs_bucket") - config.StorageClass = r.Form.Get("gcs_storage_class") - config.KeyPrefix = r.Form.Get("gcs_key_prefix") - autoCredentials := r.Form.Get("gcs_auto_credentials") - if autoCredentials != "" { - config.AutomaticCredentials = 1 - } else { - config.AutomaticCredentials = 0 - } - credentials, _, err := r.FormFile("gcs_credential_file") - if err == http.ErrMissingFile { - return config, nil - } - if err != nil { - return config, err - } - defer credentials.Close() - fileBytes, err := io.ReadAll(credentials) - if err != nil || len(fileBytes) == 0 { - if len(fileBytes) == 0 { - err = errors.New("credentials file size must be greater than 0") - } - return config, err - } - config.Credentials = kms.NewPlainSecret(string(fileBytes)) - config.AutomaticCredentials = 0 - return config, err -} - -func getSFTPConfig(r *http.Request) (vfs.SFTPFsConfig, error) { - var err error - config := vfs.SFTPFsConfig{} - config.Endpoint = r.Form.Get("sftp_endpoint") - config.Username = r.Form.Get("sftp_username") - config.Password = getSecretFromFormField(r, "sftp_password") - config.PrivateKey = getSecretFromFormField(r, "sftp_private_key") - fingerprintsFormValue := r.Form.Get("sftp_fingerprints") - config.Fingerprints = getSliceFromDelimitedValues(fingerprintsFormValue, "\n") - config.Prefix = r.Form.Get("sftp_prefix") - config.DisableCouncurrentReads = len(r.Form.Get("sftp_disable_concurrent_reads")) > 0 - config.BufferSize, err = strconv.ParseInt(r.Form.Get("sftp_buffer_size"), 10, 64) - return config, err -} - -func getAzureConfig(r *http.Request) (vfs.AzBlobFsConfig, error) { - var err error - config := vfs.AzBlobFsConfig{} - config.Container = r.Form.Get("az_container") - config.AccountName = r.Form.Get("az_account_name") - config.AccountKey = getSecretFromFormField(r, "az_account_key") - config.SASURL = r.Form.Get("az_sas_url") - config.Endpoint = r.Form.Get("az_endpoint") - config.KeyPrefix = r.Form.Get("az_key_prefix") - config.AccessTier = r.Form.Get("az_access_tier") - config.UseEmulator = len(r.Form.Get("az_use_emulator")) > 0 - config.UploadPartSize, err = strconv.ParseInt(r.Form.Get("az_upload_part_size"), 10, 64) - if err != nil { - return config, err - } - config.UploadConcurrency, err = strconv.Atoi(r.Form.Get("az_upload_concurrency")) - return config, err -} - -func getFsConfigFromPostFields(r *http.Request) (vfs.Filesystem, error) { - var fs vfs.Filesystem - provider, err := strconv.Atoi(r.Form.Get("fs_provider")) - if err != nil { - provider = int(vfs.LocalFilesystemProvider) - } - fs.Provider = vfs.FilesystemProvider(provider) - switch fs.Provider { - case vfs.S3FilesystemProvider: - config, err := getS3Config(r) - if err != nil { - return fs, err - } - fs.S3Config = config - case vfs.AzureBlobFilesystemProvider: - config, err := getAzureConfig(r) - if err != nil { - return fs, err - } - fs.AzBlobConfig = config - case vfs.GCSFilesystemProvider: - config, err := getGCSConfig(r) - if err != nil { - return fs, err - } - fs.GCSConfig = config - case vfs.CryptedFilesystemProvider: - fs.CryptConfig.Passphrase = getSecretFromFormField(r, "crypt_passphrase") - case vfs.SFTPFilesystemProvider: - config, err := getSFTPConfig(r) - if err != nil { - return fs, err - } - fs.SFTPConfig = config - } - return fs, nil -} - -func getAdminFromPostFields(r *http.Request) (dataprovider.Admin, error) { - var admin dataprovider.Admin - err := r.ParseForm() - if err != nil { - return admin, err - } - status, err := strconv.Atoi(r.Form.Get("status")) - if err != nil { - return admin, err - } - admin.Username = r.Form.Get("username") - admin.Password = r.Form.Get("password") - admin.Permissions = r.Form["permissions"] - admin.Email = r.Form.Get("email") - admin.Status = status - admin.Filters.AllowList = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",") - admin.AdditionalInfo = r.Form.Get("additional_info") - admin.Description = r.Form.Get("description") - return admin, nil -} - -func replacePlaceholders(field string, replacements map[string]string) string { - for k, v := range replacements { - field = strings.ReplaceAll(field, k, v) - } - return field -} - -func getFolderFromTemplate(folder vfs.BaseVirtualFolder, name string) vfs.BaseVirtualFolder { - folder.Name = name - replacements := make(map[string]string) - replacements["%name%"] = folder.Name - - folder.MappedPath = replacePlaceholders(folder.MappedPath, replacements) - folder.Description = replacePlaceholders(folder.Description, replacements) - switch folder.FsConfig.Provider { - case vfs.CryptedFilesystemProvider: - folder.FsConfig.CryptConfig = getCryptFsFromTemplate(folder.FsConfig.CryptConfig, replacements) - case vfs.S3FilesystemProvider: - folder.FsConfig.S3Config = getS3FsFromTemplate(folder.FsConfig.S3Config, replacements) - case vfs.GCSFilesystemProvider: - folder.FsConfig.GCSConfig = getGCSFsFromTemplate(folder.FsConfig.GCSConfig, replacements) - case vfs.AzureBlobFilesystemProvider: - folder.FsConfig.AzBlobConfig = getAzBlobFsFromTemplate(folder.FsConfig.AzBlobConfig, replacements) - case vfs.SFTPFilesystemProvider: - folder.FsConfig.SFTPConfig = getSFTPFsFromTemplate(folder.FsConfig.SFTPConfig, replacements) - } - - return folder -} - -func getCryptFsFromTemplate(fsConfig vfs.CryptFsConfig, replacements map[string]string) vfs.CryptFsConfig { - if fsConfig.Passphrase != nil { - if fsConfig.Passphrase.IsPlain() { - payload := replacePlaceholders(fsConfig.Passphrase.GetPayload(), replacements) - fsConfig.Passphrase = kms.NewPlainSecret(payload) - } - } - return fsConfig -} - -func getS3FsFromTemplate(fsConfig vfs.S3FsConfig, replacements map[string]string) vfs.S3FsConfig { - fsConfig.KeyPrefix = replacePlaceholders(fsConfig.KeyPrefix, replacements) - fsConfig.AccessKey = replacePlaceholders(fsConfig.AccessKey, replacements) - if fsConfig.AccessSecret != nil && fsConfig.AccessSecret.IsPlain() { - payload := replacePlaceholders(fsConfig.AccessSecret.GetPayload(), replacements) - fsConfig.AccessSecret = kms.NewPlainSecret(payload) - } - return fsConfig -} - -func getGCSFsFromTemplate(fsConfig vfs.GCSFsConfig, replacements map[string]string) vfs.GCSFsConfig { - fsConfig.KeyPrefix = replacePlaceholders(fsConfig.KeyPrefix, replacements) - return fsConfig -} - -func getAzBlobFsFromTemplate(fsConfig vfs.AzBlobFsConfig, replacements map[string]string) vfs.AzBlobFsConfig { - fsConfig.KeyPrefix = replacePlaceholders(fsConfig.KeyPrefix, replacements) - fsConfig.AccountName = replacePlaceholders(fsConfig.AccountName, replacements) - if fsConfig.AccountKey != nil && fsConfig.AccountKey.IsPlain() { - payload := replacePlaceholders(fsConfig.AccountKey.GetPayload(), replacements) - fsConfig.AccountKey = kms.NewPlainSecret(payload) - } - return fsConfig -} - -func getSFTPFsFromTemplate(fsConfig vfs.SFTPFsConfig, replacements map[string]string) vfs.SFTPFsConfig { - fsConfig.Prefix = replacePlaceholders(fsConfig.Prefix, replacements) - fsConfig.Username = replacePlaceholders(fsConfig.Username, replacements) - if fsConfig.Password != nil && fsConfig.Password.IsPlain() { - payload := replacePlaceholders(fsConfig.Password.GetPayload(), replacements) - fsConfig.Password = kms.NewPlainSecret(payload) - } - return fsConfig -} - -func getUserFromTemplate(user dataprovider.User, template userTemplateFields) dataprovider.User { - user.Username = template.Username - user.Password = template.Password - user.PublicKeys = nil - if template.PublicKey != "" { - user.PublicKeys = append(user.PublicKeys, template.PublicKey) - } - replacements := make(map[string]string) - replacements["%username%"] = user.Username - user.Password = replacePlaceholders(user.Password, replacements) - replacements["%password%"] = user.Password - - user.HomeDir = replacePlaceholders(user.HomeDir, replacements) - var vfolders []vfs.VirtualFolder - for _, vfolder := range user.VirtualFolders { - vfolder.Name = replacePlaceholders(vfolder.Name, replacements) - vfolder.VirtualPath = replacePlaceholders(vfolder.VirtualPath, replacements) - vfolders = append(vfolders, vfolder) - } - user.VirtualFolders = vfolders - user.Description = replacePlaceholders(user.Description, replacements) - user.AdditionalInfo = replacePlaceholders(user.AdditionalInfo, replacements) - - switch user.FsConfig.Provider { - case vfs.CryptedFilesystemProvider: - user.FsConfig.CryptConfig = getCryptFsFromTemplate(user.FsConfig.CryptConfig, replacements) - case vfs.S3FilesystemProvider: - user.FsConfig.S3Config = getS3FsFromTemplate(user.FsConfig.S3Config, replacements) - case vfs.GCSFilesystemProvider: - user.FsConfig.GCSConfig = getGCSFsFromTemplate(user.FsConfig.GCSConfig, replacements) - case vfs.AzureBlobFilesystemProvider: - user.FsConfig.AzBlobConfig = getAzBlobFsFromTemplate(user.FsConfig.AzBlobConfig, replacements) - case vfs.SFTPFilesystemProvider: - user.FsConfig.SFTPConfig = getSFTPFsFromTemplate(user.FsConfig.SFTPConfig, replacements) - } - - return user -} - -func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { - var user dataprovider.User - err := r.ParseMultipartForm(maxRequestSize) - if err != nil { - return user, err - } - publicKeysFormValue := r.Form.Get("public_keys") - publicKeys := getSliceFromDelimitedValues(publicKeysFormValue, "\n") - uid, err := strconv.Atoi(r.Form.Get("uid")) - if err != nil { - return user, err - } - gid, err := strconv.Atoi(r.Form.Get("gid")) - if err != nil { - return user, err - } - maxSessions, err := strconv.Atoi(r.Form.Get("max_sessions")) - if err != nil { - return user, err - } - quotaSize, err := strconv.ParseInt(r.Form.Get("quota_size"), 10, 64) - if err != nil { - return user, err - } - quotaFiles, err := strconv.Atoi(r.Form.Get("quota_files")) - if err != nil { - return user, err - } - bandwidthUL, err := strconv.ParseInt(r.Form.Get("upload_bandwidth"), 10, 64) - if err != nil { - return user, err - } - bandwidthDL, err := strconv.ParseInt(r.Form.Get("download_bandwidth"), 10, 64) - if err != nil { - return user, err - } - status, err := strconv.Atoi(r.Form.Get("status")) - if err != nil { - return user, err - } - expirationDateMillis := int64(0) - expirationDateString := r.Form.Get("expiration_date") - if len(strings.TrimSpace(expirationDateString)) > 0 { - expirationDate, err := time.Parse(webDateTimeFormat, expirationDateString) - if err != nil { - return user, err - } - expirationDateMillis = utils.GetTimeAsMsSinceEpoch(expirationDate) - } - fsConfig, err := getFsConfigFromPostFields(r) - if err != nil { - return user, err - } - user = dataprovider.User{ - Username: r.Form.Get("username"), - Password: r.Form.Get("password"), - PublicKeys: publicKeys, - HomeDir: r.Form.Get("home_dir"), - VirtualFolders: getVirtualFoldersFromPostFields(r), - UID: uid, - GID: gid, - Permissions: getUserPermissionsFromPostFields(r), - MaxSessions: maxSessions, - QuotaSize: quotaSize, - QuotaFiles: quotaFiles, - UploadBandwidth: bandwidthUL, - DownloadBandwidth: bandwidthDL, - Status: status, - ExpirationDate: expirationDateMillis, - Filters: getFiltersFromUserPostFields(r), - FsConfig: fsConfig, - AdditionalInfo: r.Form.Get("additional_info"), - Description: r.Form.Get("description"), - } - maxFileSize, err := strconv.ParseInt(r.Form.Get("max_upload_file_size"), 10, 64) - user.Filters.MaxUploadFileSize = maxFileSize - return user, err -} - -func renderLoginPage(w http.ResponseWriter, error string) { - data := loginPage{ - CurrentURL: webLoginPath, - Version: version.Get().Version, - Error: error, - CSRFToken: createCSRFToken(), - StaticURL: webStaticFilesPath, - } - renderTemplate(w, templateLogin, data) -} - -func handleWebAdminChangePwd(w http.ResponseWriter, r *http.Request) { - renderChangePwdPage(w, r, "") -} - -func handleWebAdminChangePwdPost(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - err := r.ParseForm() - if err != nil { - renderChangePwdPage(w, r, err.Error()) - return - } - if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { - renderForbiddenPage(w, r, err.Error()) - return - } - err = doChangeAdminPassword(r, r.Form.Get("current_password"), r.Form.Get("new_password1"), - r.Form.Get("new_password2")) - if err != nil { - renderChangePwdPage(w, r, err.Error()) - return - } - handleWebLogout(w, r) -} - -func handleWebLogout(w http.ResponseWriter, r *http.Request) { - c := jwtTokenClaims{} - c.removeCookie(w, r) - - http.Redirect(w, r, webLoginPath, http.StatusFound) -} - -func handleWebLogin(w http.ResponseWriter, r *http.Request) { - renderLoginPage(w, "") -} - -func handleWebMaintenance(w http.ResponseWriter, r *http.Request) { - renderMaintenancePage(w, r, "") -} - -func handleWebRestore(w http.ResponseWriter, r *http.Request) { - err := r.ParseMultipartForm(MaxRestoreSize) - if err != nil { - renderMaintenancePage(w, r, err.Error()) - return - } - if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { - renderForbiddenPage(w, r, err.Error()) - return - } - restoreMode, err := strconv.Atoi(r.Form.Get("mode")) - if err != nil { - renderMaintenancePage(w, r, err.Error()) - return - } - scanQuota, err := strconv.Atoi(r.Form.Get("quota")) - if err != nil { - renderMaintenancePage(w, r, err.Error()) - return - } - backupFile, _, err := r.FormFile("backup_file") - if err != nil { - renderMaintenancePage(w, r, err.Error()) - return - } - defer backupFile.Close() - - backupContent, err := io.ReadAll(backupFile) - if err != nil || len(backupContent) == 0 { - if len(backupContent) == 0 { - err = errors.New("backup file size must be greater than 0") - } - renderMaintenancePage(w, r, err.Error()) - return - } - - if err := restoreBackup(backupContent, "", scanQuota, restoreMode); err != nil { - renderMaintenancePage(w, r, err.Error()) - return - } - - renderMessagePage(w, r, "Data restored", "", http.StatusOK, nil, "Your backup was successfully restored") -} - -func handleGetWebAdmins(w http.ResponseWriter, r *http.Request) { - limit := defaultQueryLimit - if _, ok := r.URL.Query()["qlimit"]; ok { - var err error - limit, err = strconv.Atoi(r.URL.Query().Get("qlimit")) - if err != nil { - limit = defaultQueryLimit - } - } - admins := make([]dataprovider.Admin, 0, limit) - for { - a, err := dataprovider.GetAdmins(limit, len(admins), dataprovider.OrderASC) - if err != nil { - renderInternalServerErrorPage(w, r, err) - return - } - admins = append(admins, a...) - if len(a) < limit { - break - } - } - data := adminsPage{ - basePage: getBasePageData(pageAdminsTitle, webAdminsPath, r), - Admins: admins, - } - renderTemplate(w, templateAdmins, data) -} - -func handleWebAddAdminGet(w http.ResponseWriter, r *http.Request) { - admin := &dataprovider.Admin{Status: 1} - renderAddUpdateAdminPage(w, r, admin, "", true) -} - -func handleWebUpdateAdminGet(w http.ResponseWriter, r *http.Request) { - username := getURLParam(r, "username") - admin, err := dataprovider.AdminExists(username) - if err == nil { - renderAddUpdateAdminPage(w, r, &admin, "", false) - } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { - renderNotFoundPage(w, r, err) - } else { - renderInternalServerErrorPage(w, r, err) - } -} - -func handleWebAddAdminPost(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - admin, err := getAdminFromPostFields(r) - if err != nil { - renderAddUpdateAdminPage(w, r, &admin, err.Error(), true) - return - } - if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { - renderForbiddenPage(w, r, err.Error()) - return - } - err = dataprovider.AddAdmin(&admin) - if err != nil { - renderAddUpdateAdminPage(w, r, &admin, err.Error(), true) - return - } - http.Redirect(w, r, webAdminsPath, http.StatusSeeOther) -} - -func handleWebUpdateAdminPost(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - - username := getURLParam(r, "username") - admin, err := dataprovider.AdminExists(username) - if _, ok := err.(*dataprovider.RecordNotFoundError); ok { - renderNotFoundPage(w, r, err) - return - } else if err != nil { - renderInternalServerErrorPage(w, r, err) - return - } - - updatedAdmin, err := getAdminFromPostFields(r) - if err != nil { - renderAddUpdateAdminPage(w, r, &updatedAdmin, err.Error(), false) - return - } - if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { - renderForbiddenPage(w, r, err.Error()) - return - } - updatedAdmin.ID = admin.ID - updatedAdmin.Username = admin.Username - if updatedAdmin.Password == "" { - updatedAdmin.Password = admin.Password - } - claims, err := getTokenClaims(r) - if err != nil || claims.Username == "" { - renderAddUpdateAdminPage(w, r, &updatedAdmin, fmt.Sprintf("Invalid token claims: %v", err), false) - return - } - if username == claims.Username { - if claims.isCriticalPermRemoved(updatedAdmin.Permissions) { - renderAddUpdateAdminPage(w, r, &updatedAdmin, "You cannot remove these permissions to yourself", false) - return - } - if updatedAdmin.Status == 0 { - renderAddUpdateAdminPage(w, r, &updatedAdmin, "You cannot disable yourself", false) - return - } - } - err = dataprovider.UpdateAdmin(&updatedAdmin) - if err != nil { - renderAddUpdateAdminPage(w, r, &admin, err.Error(), false) - return - } - http.Redirect(w, r, webAdminsPath, http.StatusSeeOther) -} - -func handleGetWebUsers(w http.ResponseWriter, r *http.Request) { - limit := defaultQueryLimit - if _, ok := r.URL.Query()["qlimit"]; ok { - var err error - limit, err = strconv.Atoi(r.URL.Query().Get("qlimit")) - if err != nil { - limit = defaultQueryLimit - } - } - users := make([]dataprovider.User, 0, limit) - for { - u, err := dataprovider.GetUsers(limit, len(users), dataprovider.OrderASC) - if err != nil { - renderInternalServerErrorPage(w, r, err) - return - } - users = append(users, u...) - if len(u) < limit { - break - } - } - data := usersPage{ - basePage: getBasePageData(pageUsersTitle, webUsersPath, r), - Users: users, - } - renderTemplate(w, templateUsers, data) -} - -func handleWebTemplateFolderGet(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("from") != "" { - name := r.URL.Query().Get("from") - folder, err := dataprovider.GetFolderByName(name) - if err == nil { - renderFolderPage(w, r, folder, folderPageModeTemplate, "") - } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { - renderNotFoundPage(w, r, err) - } else { - renderInternalServerErrorPage(w, r, err) - } - } else { - folder := vfs.BaseVirtualFolder{} - renderFolderPage(w, r, folder, folderPageModeTemplate, "") - } -} - -func handleWebTemplateFolderPost(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - templateFolder := vfs.BaseVirtualFolder{} - err := r.ParseForm() - if err != nil { - renderMessagePage(w, r, "Error parsing folders fields", "", http.StatusBadRequest, err, "") - return - } - - if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { - renderForbiddenPage(w, r, err.Error()) - return - } - - templateFolder.MappedPath = r.Form.Get("mapped_path") - templateFolder.Description = r.Form.Get("description") - fsConfig, err := getFsConfigFromPostFields(r) - if err != nil { - renderMessagePage(w, r, "Error parsing folders fields", "", http.StatusBadRequest, err, "") - return - } - templateFolder.FsConfig = fsConfig - - var dump dataprovider.BackupData - dump.Version = dataprovider.DumpVersion - - foldersFields := getFoldersForTemplate(r) - for _, tmpl := range foldersFields { - f := getFolderFromTemplate(templateFolder, tmpl) - if err := dataprovider.ValidateFolder(&f); err != nil { - renderMessagePage(w, r, fmt.Sprintf("Error validating folder %#v", f.Name), "", http.StatusBadRequest, err, "") - return - } - dump.Folders = append(dump.Folders, f) - } - - if len(dump.Folders) == 0 { - renderMessagePage(w, r, "No folders to export", "No valid folders found, export is not possible", http.StatusBadRequest, nil, "") - return - } - - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"sftpgo-%v-folders-from-template.json\"", len(dump.Folders))) - render.JSON(w, r, dump) -} - -func handleWebTemplateUserGet(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("from") != "" { - username := r.URL.Query().Get("from") - user, err := dataprovider.UserExists(username) - if err == nil { - user.SetEmptySecrets() - renderUserPage(w, r, &user, userPageModeTemplate, "") - } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { - renderNotFoundPage(w, r, err) - } else { - renderInternalServerErrorPage(w, r, err) - } - } else { - user := dataprovider.User{Status: 1} - renderUserPage(w, r, &user, userPageModeTemplate, "") - } -} - -func handleWebTemplateUserPost(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - templateUser, err := getUserFromPostFields(r) - if err != nil { - renderMessagePage(w, r, "Error parsing user fields", "", http.StatusBadRequest, err, "") - return - } - if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { - renderForbiddenPage(w, r, err.Error()) - return - } - - var dump dataprovider.BackupData - dump.Version = dataprovider.DumpVersion - - userTmplFields := getUsersForTemplate(r) - for _, tmpl := range userTmplFields { - u := getUserFromTemplate(templateUser, tmpl) - if err := dataprovider.ValidateUser(&u); err != nil { - renderMessagePage(w, r, fmt.Sprintf("Error validating user %#v", u.Username), "", http.StatusBadRequest, err, "") - return - } - dump.Users = append(dump.Users, u) - for _, folder := range u.VirtualFolders { - if !dump.HasFolder(folder.Name) { - dump.Folders = append(dump.Folders, folder.BaseVirtualFolder) - } - } - } - - if len(dump.Users) == 0 { - renderMessagePage(w, r, "No users to export", "No valid users found, export is not possible", http.StatusBadRequest, nil, "") - return - } - - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"sftpgo-%v-users-from-template.json\"", len(dump.Users))) - render.JSON(w, r, dump) -} - -func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("clone-from") != "" { - username := r.URL.Query().Get("clone-from") - user, err := dataprovider.UserExists(username) - if err == nil { - user.ID = 0 - user.Username = "" - user.Password = "" - user.SetEmptySecrets() - renderUserPage(w, r, &user, userPageModeAdd, "") - } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { - renderNotFoundPage(w, r, err) - } else { - renderInternalServerErrorPage(w, r, err) - } - } else { - user := dataprovider.User{Status: 1} - renderUserPage(w, r, &user, userPageModeAdd, "") - } -} - -func handleWebUpdateUserGet(w http.ResponseWriter, r *http.Request) { - username := getURLParam(r, "username") - user, err := dataprovider.UserExists(username) - if err == nil { - renderUserPage(w, r, &user, userPageModeUpdate, "") - } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { - renderNotFoundPage(w, r, err) - } else { - renderInternalServerErrorPage(w, r, err) - } -} - -func handleWebAddUserPost(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - user, err := getUserFromPostFields(r) - if err != nil { - renderUserPage(w, r, &user, userPageModeAdd, err.Error()) - return - } - if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { - renderForbiddenPage(w, r, err.Error()) - return - } - err = dataprovider.AddUser(&user) - if err == nil { - http.Redirect(w, r, webUsersPath, http.StatusSeeOther) - } else { - renderUserPage(w, r, &user, userPageModeAdd, err.Error()) - } -} - -func handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - username := getURLParam(r, "username") - user, err := dataprovider.UserExists(username) - if _, ok := err.(*dataprovider.RecordNotFoundError); ok { - renderNotFoundPage(w, r, err) - return - } else if err != nil { - renderInternalServerErrorPage(w, r, err) - return - } - updatedUser, err := getUserFromPostFields(r) - if err != nil { - renderUserPage(w, r, &user, userPageModeUpdate, err.Error()) - return - } - if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { - renderForbiddenPage(w, r, err.Error()) - return - } - updatedUser.ID = user.ID - updatedUser.Username = user.Username - updatedUser.SetEmptySecretsIfNil() - if updatedUser.Password == redactedSecret { - updatedUser.Password = user.Password - } - updateEncryptedSecrets(&updatedUser.FsConfig, user.FsConfig.S3Config.AccessSecret, user.FsConfig.AzBlobConfig.AccountKey, - user.FsConfig.GCSConfig.Credentials, user.FsConfig.CryptConfig.Passphrase, user.FsConfig.SFTPConfig.Password, - user.FsConfig.SFTPConfig.PrivateKey) - - err = dataprovider.UpdateUser(&updatedUser) - if err == nil { - if len(r.Form.Get("disconnect")) > 0 { - disconnectUser(user.Username) - } - http.Redirect(w, r, webUsersPath, http.StatusSeeOther) - } else { - renderUserPage(w, r, &user, userPageModeUpdate, err.Error()) - } -} - -func handleWebGetStatus(w http.ResponseWriter, r *http.Request) { - data := statusPage{ - basePage: getBasePageData(pageStatusTitle, webStatusPath, r), - Status: getServicesStatus(), - } - renderTemplate(w, templateStatus, data) -} - -func handleWebGetConnections(w http.ResponseWriter, r *http.Request) { - connectionStats := common.Connections.GetStats() - data := connectionsPage{ - basePage: getBasePageData(pageConnectionsTitle, webConnectionsPath, r), - Connections: connectionStats, - } - renderTemplate(w, templateConnections, data) -} - -func handleWebAddFolderGet(w http.ResponseWriter, r *http.Request) { - renderFolderPage(w, r, vfs.BaseVirtualFolder{}, folderPageModeAdd, "") -} - -func handleWebAddFolderPost(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - folder := vfs.BaseVirtualFolder{} - err := r.ParseMultipartForm(maxRequestSize) - if err != nil { - renderFolderPage(w, r, folder, folderPageModeAdd, err.Error()) - return - } - if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { - renderForbiddenPage(w, r, err.Error()) - return - } - folder.MappedPath = r.Form.Get("mapped_path") - folder.Name = r.Form.Get("name") - folder.Description = r.Form.Get("description") - fsConfig, err := getFsConfigFromPostFields(r) - if err != nil { - renderFolderPage(w, r, folder, folderPageModeAdd, err.Error()) - return - } - folder.FsConfig = fsConfig - - err = dataprovider.AddFolder(&folder) - if err == nil { - http.Redirect(w, r, webFoldersPath, http.StatusSeeOther) - } else { - renderFolderPage(w, r, folder, folderPageModeAdd, err.Error()) - } -} - -func handleWebUpdateFolderGet(w http.ResponseWriter, r *http.Request) { - name := getURLParam(r, "name") - folder, err := dataprovider.GetFolderByName(name) - if err == nil { - renderFolderPage(w, r, folder, folderPageModeUpdate, "") - } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { - renderNotFoundPage(w, r, err) - } else { - renderInternalServerErrorPage(w, r, err) - } -} - -func handleWebUpdateFolderPost(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - name := getURLParam(r, "name") - folder, err := dataprovider.GetFolderByName(name) - if _, ok := err.(*dataprovider.RecordNotFoundError); ok { - renderNotFoundPage(w, r, err) - return - } else if err != nil { - renderInternalServerErrorPage(w, r, err) - return - } - - err = r.ParseMultipartForm(maxRequestSize) - if err != nil { - renderFolderPage(w, r, folder, folderPageModeUpdate, err.Error()) - return - } - if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { - renderForbiddenPage(w, r, err.Error()) - return - } - fsConfig, err := getFsConfigFromPostFields(r) - if err != nil { - renderFolderPage(w, r, folder, folderPageModeUpdate, err.Error()) - return - } - updatedFolder := &vfs.BaseVirtualFolder{ - MappedPath: r.Form.Get("mapped_path"), - Description: r.Form.Get("description"), - } - updatedFolder.ID = folder.ID - updatedFolder.Name = folder.Name - updatedFolder.FsConfig = fsConfig - updatedFolder.FsConfig.SetEmptySecretsIfNil() - updateEncryptedSecrets(&updatedFolder.FsConfig, folder.FsConfig.S3Config.AccessSecret, folder.FsConfig.AzBlobConfig.AccountKey, - folder.FsConfig.GCSConfig.Credentials, folder.FsConfig.CryptConfig.Passphrase, folder.FsConfig.SFTPConfig.Password, - folder.FsConfig.SFTPConfig.PrivateKey) - - err = dataprovider.UpdateFolder(updatedFolder, folder.Users) - if err != nil { - renderFolderPage(w, r, folder, folderPageModeUpdate, err.Error()) - return - } - http.Redirect(w, r, webFoldersPath, http.StatusSeeOther) -} - -func handleWebGetFolders(w http.ResponseWriter, r *http.Request) { - limit := defaultQueryLimit - if _, ok := r.URL.Query()["qlimit"]; ok { - var err error - limit, err = strconv.Atoi(r.URL.Query().Get("qlimit")) - if err != nil { - limit = defaultQueryLimit - } - } - folders := make([]vfs.BaseVirtualFolder, 0, limit) - for { - f, err := dataprovider.GetFolders(limit, len(folders), dataprovider.OrderASC) - if err != nil { - renderInternalServerErrorPage(w, r, err) - return - } - folders = append(folders, f...) - if len(f) < limit { - break - } - } - - data := foldersPage{ - basePage: getBasePageData(pageFoldersTitle, webFoldersPath, r), - Folders: folders, - } - renderTemplate(w, templateFolders, data) -} diff --git a/httpd/webadmin.go b/httpd/webadmin.go new file mode 100644 index 00000000..a96005b4 --- /dev/null +++ b/httpd/webadmin.go @@ -0,0 +1,1559 @@ +package httpd + +import ( + "errors" + "fmt" + "html/template" + "io" + "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/go-chi/render" + + "github.com/drakkan/sftpgo/common" + "github.com/drakkan/sftpgo/dataprovider" + "github.com/drakkan/sftpgo/kms" + "github.com/drakkan/sftpgo/utils" + "github.com/drakkan/sftpgo/version" + "github.com/drakkan/sftpgo/vfs" +) + +type userPageMode int + +const ( + userPageModeAdd userPageMode = iota + 1 + userPageModeUpdate + userPageModeTemplate +) + +type folderPageMode int + +const ( + folderPageModeAdd folderPageMode = iota + 1 + folderPageModeUpdate + folderPageModeTemplate +) + +const ( + templateAdminDir = "webadmin" + templateBase = "base.html" + templateFsConfig = "fsconfig.html" + templateUsers = "users.html" + templateUser = "user.html" + templateAdmins = "admins.html" + templateAdmin = "admin.html" + templateConnections = "connections.html" + templateFolders = "folders.html" + templateFolder = "folder.html" + templateMessage = "message.html" + templateStatus = "status.html" + templateLogin = "login.html" + templateChangePwd = "changepwd.html" + templateMaintenance = "maintenance.html" + pageUsersTitle = "Users" + pageAdminsTitle = "Admins" + pageConnectionsTitle = "Connections" + pageStatusTitle = "Status" + pageFoldersTitle = "Folders" + pageChangePwdTitle = "Change password" + pageMaintenanceTitle = "Maintenance" + defaultQueryLimit = 500 +) + +var ( + adminTemplates = make(map[string]*template.Template) +) + +type basePage struct { + Title string + CurrentURL string + UsersURL string + UserURL string + UserTemplateURL string + AdminsURL string + AdminURL string + QuotaScanURL string + ConnectionsURL string + FoldersURL string + FolderURL string + FolderTemplateURL string + LogoutURL string + ChangeAdminPwdURL string + FolderQuotaScanURL string + StatusURL string + MaintenanceURL string + StaticURL string + UsersTitle string + AdminsTitle string + ConnectionsTitle string + FoldersTitle string + StatusTitle string + MaintenanceTitle string + Version string + CSRFToken string + LoggedAdmin *dataprovider.Admin +} + +type usersPage struct { + basePage + Users []dataprovider.User +} + +type adminsPage struct { + basePage + Admins []dataprovider.Admin +} + +type foldersPage struct { + basePage + Folders []vfs.BaseVirtualFolder +} + +type connectionsPage struct { + basePage + Connections []*common.ConnectionStatus +} + +type statusPage struct { + basePage + Status ServicesStatus +} + +type userPage struct { + basePage + User *dataprovider.User + RootPerms []string + Error string + ValidPerms []string + ValidLoginMethods []string + ValidProtocols []string + WebClientOptions []string + RootDirPerms []string + RedactedSecret string + Mode userPageMode +} + +type adminPage struct { + basePage + Admin *dataprovider.Admin + Error string + IsAdd bool +} + +type changePwdPage struct { + basePage + Error string +} + +type maintenancePage struct { + basePage + BackupPath string + RestorePath string + Error string +} + +type folderPage struct { + basePage + Folder vfs.BaseVirtualFolder + Error string + Mode folderPageMode +} + +type messagePage struct { + basePage + Error string + Success string +} + +type userTemplateFields struct { + Username string + Password string + PublicKey string +} + +func loadAdminTemplates(templatesPath string) { + usersPaths := []string{ + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateUsers), + } + userPaths := []string{ + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateFsConfig), + filepath.Join(templatesPath, templateAdminDir, templateUser), + } + adminsPaths := []string{ + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateAdmins), + } + adminPaths := []string{ + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateAdmin), + } + changePwdPaths := []string{ + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateChangePwd), + } + connectionsPaths := []string{ + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateConnections), + } + messagePath := []string{ + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateMessage), + } + foldersPath := []string{ + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateFolders), + } + folderPath := []string{ + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateFsConfig), + filepath.Join(templatesPath, templateAdminDir, templateFolder), + } + statusPath := []string{ + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateStatus), + } + loginPath := []string{ + filepath.Join(templatesPath, templateAdminDir, templateLogin), + } + maintenancePath := []string{ + filepath.Join(templatesPath, templateAdminDir, templateBase), + filepath.Join(templatesPath, templateAdminDir, templateMaintenance), + } + usersTmpl := utils.LoadTemplate(template.ParseFiles(usersPaths...)) + userTmpl := utils.LoadTemplate(template.ParseFiles(userPaths...)) + adminsTmpl := utils.LoadTemplate(template.ParseFiles(adminsPaths...)) + adminTmpl := utils.LoadTemplate(template.ParseFiles(adminPaths...)) + connectionsTmpl := utils.LoadTemplate(template.ParseFiles(connectionsPaths...)) + messageTmpl := utils.LoadTemplate(template.ParseFiles(messagePath...)) + foldersTmpl := utils.LoadTemplate(template.ParseFiles(foldersPath...)) + folderTmpl := utils.LoadTemplate(template.ParseFiles(folderPath...)) + statusTmpl := utils.LoadTemplate(template.ParseFiles(statusPath...)) + loginTmpl := utils.LoadTemplate(template.ParseFiles(loginPath...)) + changePwdTmpl := utils.LoadTemplate(template.ParseFiles(changePwdPaths...)) + maintenanceTmpl := utils.LoadTemplate(template.ParseFiles(maintenancePath...)) + + adminTemplates[templateUsers] = usersTmpl + adminTemplates[templateUser] = userTmpl + adminTemplates[templateAdmins] = adminsTmpl + adminTemplates[templateAdmin] = adminTmpl + adminTemplates[templateConnections] = connectionsTmpl + adminTemplates[templateMessage] = messageTmpl + adminTemplates[templateFolders] = foldersTmpl + adminTemplates[templateFolder] = folderTmpl + adminTemplates[templateStatus] = statusTmpl + adminTemplates[templateLogin] = loginTmpl + adminTemplates[templateChangePwd] = changePwdTmpl + adminTemplates[templateMaintenance] = maintenanceTmpl +} + +func getBasePageData(title, currentURL string, r *http.Request) basePage { + var csrfToken string + if currentURL != "" { + csrfToken = createCSRFToken() + } + return basePage{ + Title: title, + CurrentURL: currentURL, + UsersURL: webUsersPath, + UserURL: webUserPath, + UserTemplateURL: webTemplateUser, + AdminsURL: webAdminsPath, + AdminURL: webAdminPath, + FoldersURL: webFoldersPath, + FolderURL: webFolderPath, + FolderTemplateURL: webTemplateFolder, + LogoutURL: webLogoutPath, + ChangeAdminPwdURL: webChangeAdminPwdPath, + QuotaScanURL: webQuotaScanPath, + ConnectionsURL: webConnectionsPath, + StatusURL: webStatusPath, + FolderQuotaScanURL: webScanVFolderPath, + MaintenanceURL: webMaintenancePath, + StaticURL: webStaticFilesPath, + UsersTitle: pageUsersTitle, + AdminsTitle: pageAdminsTitle, + ConnectionsTitle: pageConnectionsTitle, + FoldersTitle: pageFoldersTitle, + StatusTitle: pageStatusTitle, + MaintenanceTitle: pageMaintenanceTitle, + Version: version.GetAsString(), + LoggedAdmin: getAdminFromToken(r), + CSRFToken: csrfToken, + } +} + +func renderAdminTemplate(w http.ResponseWriter, tmplName string, data interface{}) { + err := adminTemplates[tmplName].ExecuteTemplate(w, tmplName, data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func renderMessagePage(w http.ResponseWriter, r *http.Request, title, body string, statusCode int, err error, message string) { + var errorString string + if body != "" { + errorString = body + " " + } + if err != nil { + errorString += err.Error() + } + data := messagePage{ + basePage: getBasePageData(title, "", r), + Error: errorString, + Success: message, + } + w.WriteHeader(statusCode) + renderAdminTemplate(w, templateMessage, data) +} + +func renderInternalServerErrorPage(w http.ResponseWriter, r *http.Request, err error) { + renderMessagePage(w, r, page500Title, page500Body, http.StatusInternalServerError, err, "") +} + +func renderBadRequestPage(w http.ResponseWriter, r *http.Request, err error) { + renderMessagePage(w, r, page400Title, "", http.StatusBadRequest, err, "") +} + +func renderForbiddenPage(w http.ResponseWriter, r *http.Request, body string) { + renderMessagePage(w, r, page403Title, "", http.StatusForbidden, nil, body) +} + +func renderNotFoundPage(w http.ResponseWriter, r *http.Request, err error) { + renderMessagePage(w, r, page404Title, page404Body, http.StatusNotFound, err, "") +} + +func renderChangePwdPage(w http.ResponseWriter, r *http.Request, error string) { + data := changePwdPage{ + basePage: getBasePageData(pageChangePwdTitle, webChangeAdminPwdPath, r), + Error: error, + } + + renderAdminTemplate(w, templateChangePwd, data) +} + +func renderMaintenancePage(w http.ResponseWriter, r *http.Request, error string) { + data := maintenancePage{ + basePage: getBasePageData(pageMaintenanceTitle, webMaintenancePath, r), + BackupPath: webBackupPath, + RestorePath: webRestorePath, + Error: error, + } + + renderAdminTemplate(w, templateMaintenance, data) +} + +func renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Request, admin *dataprovider.Admin, + error string, isAdd bool) { + currentURL := webAdminPath + if !isAdd { + currentURL = fmt.Sprintf("%v/%v", webAdminPath, url.PathEscape(admin.Username)) + } + data := adminPage{ + basePage: getBasePageData("Add a new user", currentURL, r), + Admin: admin, + Error: error, + IsAdd: isAdd, + } + + renderAdminTemplate(w, templateAdmin, data) +} + +func renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.User, mode userPageMode, error string) { + user.SetEmptySecretsIfNil() + var title, currentURL string + switch mode { + case userPageModeAdd: + title = "Add a new user" + currentURL = webUserPath + case userPageModeUpdate: + title = "Update user" + currentURL = fmt.Sprintf("%v/%v", webUserPath, url.PathEscape(user.Username)) + case userPageModeTemplate: + title = "User template" + currentURL = webTemplateUser + } + if user.Password != "" && user.IsPasswordHashed() && mode == userPageModeUpdate { + user.Password = redactedSecret + } + user.FsConfig.RedactedSecret = redactedSecret + data := userPage{ + basePage: getBasePageData(title, currentURL, r), + Mode: mode, + Error: error, + User: user, + ValidPerms: dataprovider.ValidPerms, + ValidLoginMethods: dataprovider.ValidLoginMethods, + ValidProtocols: dataprovider.ValidProtocols, + WebClientOptions: dataprovider.WebClientOptions, + RootDirPerms: user.GetPermissionsForPath("/"), + } + renderAdminTemplate(w, templateUser, data) +} + +func renderFolderPage(w http.ResponseWriter, r *http.Request, folder vfs.BaseVirtualFolder, mode folderPageMode, error string) { + var title, currentURL string + switch mode { + case folderPageModeAdd: + title = "Add a new folder" + currentURL = webFolderPath + case folderPageModeUpdate: + title = "Update folder" + currentURL = fmt.Sprintf("%v/%v", webFolderPath, url.PathEscape(folder.Name)) + case folderPageModeTemplate: + title = "Folder template" + currentURL = webTemplateFolder + } + folder.FsConfig.RedactedSecret = redactedSecret + folder.FsConfig.SetEmptySecretsIfNil() + + data := folderPage{ + basePage: getBasePageData(title, currentURL, r), + Error: error, + Folder: folder, + Mode: mode, + } + renderAdminTemplate(w, templateFolder, data) +} + +func getFoldersForTemplate(r *http.Request) []string { + var res []string + formValue := r.Form.Get("folders") + folders := make(map[string]bool) + for _, name := range getSliceFromDelimitedValues(formValue, "\n") { + if _, ok := folders[name]; ok { + continue + } + folders[name] = true + res = append(res, name) + } + return res +} + +func getUsersForTemplate(r *http.Request) []userTemplateFields { + var res []userTemplateFields + formValue := r.Form.Get("users") + users := make(map[string]bool) + for _, cleaned := range getSliceFromDelimitedValues(formValue, "\n") { + if strings.Contains(cleaned, "::") { + mapping := strings.Split(cleaned, "::") + if len(mapping) > 1 { + username := strings.TrimSpace(mapping[0]) + password := strings.TrimSpace(mapping[1]) + var publicKey string + if len(mapping) > 2 { + publicKey = strings.TrimSpace(mapping[2]) + } + if username == "" || (password == "" && publicKey == "") { + continue + } + if _, ok := users[username]; ok { + continue + } + + users[username] = true + res = append(res, userTemplateFields{ + Username: username, + Password: password, + PublicKey: publicKey, + }) + } + } + } + return res +} + +func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder { + var virtualFolders []vfs.VirtualFolder + formValue := r.Form.Get("virtual_folders") + for _, cleaned := range getSliceFromDelimitedValues(formValue, "\n") { + if strings.Contains(cleaned, "::") { + mapping := strings.Split(cleaned, "::") + if len(mapping) > 1 { + vfolder := vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: strings.TrimSpace(mapping[1]), + }, + VirtualPath: strings.TrimSpace(mapping[0]), + QuotaFiles: -1, + QuotaSize: -1, + } + if len(mapping) > 2 { + quotaFiles, err := strconv.Atoi(strings.TrimSpace(mapping[2])) + if err == nil { + vfolder.QuotaFiles = quotaFiles + } + } + if len(mapping) > 3 { + quotaSize, err := strconv.ParseInt(strings.TrimSpace(mapping[3]), 10, 64) + if err == nil { + vfolder.QuotaSize = quotaSize + } + } + virtualFolders = append(virtualFolders, vfolder) + } + } + } + return virtualFolders +} + +func getUserPermissionsFromPostFields(r *http.Request) map[string][]string { + permissions := make(map[string][]string) + permissions["/"] = r.Form["permissions"] + subDirsPermsValue := r.Form.Get("sub_dirs_permissions") + for _, cleaned := range getSliceFromDelimitedValues(subDirsPermsValue, "\n") { + if strings.Contains(cleaned, "::") { + dirPerms := strings.Split(cleaned, "::") + if len(dirPerms) > 1 { + dir := dirPerms[0] + dir = strings.TrimSpace(dir) + perms := []string{} + for _, p := range strings.Split(dirPerms[1], ",") { + cleanedPerm := strings.TrimSpace(p) + if cleanedPerm != "" { + perms = append(perms, cleanedPerm) + } + } + if dir != "" { + permissions[dir] = perms + } + } + } + } + return permissions +} + +func getFilePatternsFromPostField(valueAllowed, valuesDenied string) []dataprovider.PatternsFilter { + var result []dataprovider.PatternsFilter + allowedPatterns := getListFromPostFields(valueAllowed) + deniedPatterns := getListFromPostFields(valuesDenied) + + for dirAllowed, allowPatterns := range allowedPatterns { + filter := dataprovider.PatternsFilter{ + Path: dirAllowed, + AllowedPatterns: allowPatterns, + } + for dirDenied, denPatterns := range deniedPatterns { + if dirAllowed == dirDenied { + filter.DeniedPatterns = denPatterns + break + } + } + result = append(result, filter) + } + for dirDenied, denPatterns := range deniedPatterns { + found := false + for _, res := range result { + if res.Path == dirDenied { + found = true + break + } + } + if !found { + result = append(result, dataprovider.PatternsFilter{ + Path: dirDenied, + DeniedPatterns: denPatterns, + }) + } + } + return result +} + +func getFileExtensionsFromPostField(valueAllowed, valuesDenied string) []dataprovider.ExtensionsFilter { + var result []dataprovider.ExtensionsFilter + allowedExtensions := getListFromPostFields(valueAllowed) + deniedExtensions := getListFromPostFields(valuesDenied) + + for dirAllowed, allowedExts := range allowedExtensions { + filter := dataprovider.ExtensionsFilter{ + Path: dirAllowed, + AllowedExtensions: allowedExts, + } + for dirDenied, deniedExts := range deniedExtensions { + if dirAllowed == dirDenied { + filter.DeniedExtensions = deniedExts + break + } + } + result = append(result, filter) + } + for dirDenied, deniedExts := range deniedExtensions { + found := false + for _, res := range result { + if res.Path == dirDenied { + found = true + break + } + } + if !found { + result = append(result, dataprovider.ExtensionsFilter{ + Path: dirDenied, + DeniedExtensions: deniedExts, + }) + } + } + return result +} + +func getFiltersFromUserPostFields(r *http.Request) dataprovider.UserFilters { + var filters dataprovider.UserFilters + filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",") + filters.DeniedIP = getSliceFromDelimitedValues(r.Form.Get("denied_ip"), ",") + filters.DeniedLoginMethods = r.Form["ssh_login_methods"] + filters.DeniedProtocols = r.Form["denied_protocols"] + filters.FileExtensions = getFileExtensionsFromPostField(r.Form.Get("allowed_extensions"), r.Form.Get("denied_extensions")) + filters.FilePatterns = getFilePatternsFromPostField(r.Form.Get("allowed_patterns"), r.Form.Get("denied_patterns")) + filters.TLSUsername = dataprovider.TLSUsername(r.Form.Get("tls_username")) + filters.WebClient = r.Form["web_client_options"] + hooks := r.Form["hooks"] + if utils.IsStringInSlice("external_auth_disabled", hooks) { + filters.Hooks.ExternalAuthDisabled = true + } + if utils.IsStringInSlice("pre_login_disabled", hooks) { + filters.Hooks.PreLoginDisabled = true + } + if utils.IsStringInSlice("check_password_disabled", hooks) { + filters.Hooks.CheckPasswordDisabled = true + } + filters.DisableFsChecks = len(r.Form.Get("disable_fs_checks")) > 0 + return filters +} + +func getSecretFromFormField(r *http.Request, field string) *kms.Secret { + secret := kms.NewPlainSecret(r.Form.Get(field)) + if strings.TrimSpace(secret.GetPayload()) == redactedSecret { + secret.SetStatus(kms.SecretStatusRedacted) + } + if strings.TrimSpace(secret.GetPayload()) == "" { + secret.SetStatus("") + } + return secret +} + +func getS3Config(r *http.Request) (vfs.S3FsConfig, error) { + var err error + config := vfs.S3FsConfig{} + config.Bucket = r.Form.Get("s3_bucket") + config.Region = r.Form.Get("s3_region") + config.AccessKey = r.Form.Get("s3_access_key") + config.AccessSecret = getSecretFromFormField(r, "s3_access_secret") + config.Endpoint = r.Form.Get("s3_endpoint") + config.StorageClass = r.Form.Get("s3_storage_class") + config.KeyPrefix = r.Form.Get("s3_key_prefix") + config.UploadPartSize, err = strconv.ParseInt(r.Form.Get("s3_upload_part_size"), 10, 64) + if err != nil { + return config, err + } + config.UploadConcurrency, err = strconv.Atoi(r.Form.Get("s3_upload_concurrency")) + return config, err +} + +func getGCSConfig(r *http.Request) (vfs.GCSFsConfig, error) { + var err error + config := vfs.GCSFsConfig{} + + config.Bucket = r.Form.Get("gcs_bucket") + config.StorageClass = r.Form.Get("gcs_storage_class") + config.KeyPrefix = r.Form.Get("gcs_key_prefix") + autoCredentials := r.Form.Get("gcs_auto_credentials") + if autoCredentials != "" { + config.AutomaticCredentials = 1 + } else { + config.AutomaticCredentials = 0 + } + credentials, _, err := r.FormFile("gcs_credential_file") + if err == http.ErrMissingFile { + return config, nil + } + if err != nil { + return config, err + } + defer credentials.Close() + fileBytes, err := io.ReadAll(credentials) + if err != nil || len(fileBytes) == 0 { + if len(fileBytes) == 0 { + err = errors.New("credentials file size must be greater than 0") + } + return config, err + } + config.Credentials = kms.NewPlainSecret(string(fileBytes)) + config.AutomaticCredentials = 0 + return config, err +} + +func getSFTPConfig(r *http.Request) (vfs.SFTPFsConfig, error) { + var err error + config := vfs.SFTPFsConfig{} + config.Endpoint = r.Form.Get("sftp_endpoint") + config.Username = r.Form.Get("sftp_username") + config.Password = getSecretFromFormField(r, "sftp_password") + config.PrivateKey = getSecretFromFormField(r, "sftp_private_key") + fingerprintsFormValue := r.Form.Get("sftp_fingerprints") + config.Fingerprints = getSliceFromDelimitedValues(fingerprintsFormValue, "\n") + config.Prefix = r.Form.Get("sftp_prefix") + config.DisableCouncurrentReads = len(r.Form.Get("sftp_disable_concurrent_reads")) > 0 + config.BufferSize, err = strconv.ParseInt(r.Form.Get("sftp_buffer_size"), 10, 64) + return config, err +} + +func getAzureConfig(r *http.Request) (vfs.AzBlobFsConfig, error) { + var err error + config := vfs.AzBlobFsConfig{} + config.Container = r.Form.Get("az_container") + config.AccountName = r.Form.Get("az_account_name") + config.AccountKey = getSecretFromFormField(r, "az_account_key") + config.SASURL = r.Form.Get("az_sas_url") + config.Endpoint = r.Form.Get("az_endpoint") + config.KeyPrefix = r.Form.Get("az_key_prefix") + config.AccessTier = r.Form.Get("az_access_tier") + config.UseEmulator = len(r.Form.Get("az_use_emulator")) > 0 + config.UploadPartSize, err = strconv.ParseInt(r.Form.Get("az_upload_part_size"), 10, 64) + if err != nil { + return config, err + } + config.UploadConcurrency, err = strconv.Atoi(r.Form.Get("az_upload_concurrency")) + return config, err +} + +func getFsConfigFromPostFields(r *http.Request) (vfs.Filesystem, error) { + var fs vfs.Filesystem + provider, err := strconv.Atoi(r.Form.Get("fs_provider")) + if err != nil { + provider = int(vfs.LocalFilesystemProvider) + } + fs.Provider = vfs.FilesystemProvider(provider) + switch fs.Provider { + case vfs.S3FilesystemProvider: + config, err := getS3Config(r) + if err != nil { + return fs, err + } + fs.S3Config = config + case vfs.AzureBlobFilesystemProvider: + config, err := getAzureConfig(r) + if err != nil { + return fs, err + } + fs.AzBlobConfig = config + case vfs.GCSFilesystemProvider: + config, err := getGCSConfig(r) + if err != nil { + return fs, err + } + fs.GCSConfig = config + case vfs.CryptedFilesystemProvider: + fs.CryptConfig.Passphrase = getSecretFromFormField(r, "crypt_passphrase") + case vfs.SFTPFilesystemProvider: + config, err := getSFTPConfig(r) + if err != nil { + return fs, err + } + fs.SFTPConfig = config + } + return fs, nil +} + +func getAdminFromPostFields(r *http.Request) (dataprovider.Admin, error) { + var admin dataprovider.Admin + err := r.ParseForm() + if err != nil { + return admin, err + } + status, err := strconv.Atoi(r.Form.Get("status")) + if err != nil { + return admin, err + } + admin.Username = r.Form.Get("username") + admin.Password = r.Form.Get("password") + admin.Permissions = r.Form["permissions"] + admin.Email = r.Form.Get("email") + admin.Status = status + admin.Filters.AllowList = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",") + admin.AdditionalInfo = r.Form.Get("additional_info") + admin.Description = r.Form.Get("description") + return admin, nil +} + +func replacePlaceholders(field string, replacements map[string]string) string { + for k, v := range replacements { + field = strings.ReplaceAll(field, k, v) + } + return field +} + +func getFolderFromTemplate(folder vfs.BaseVirtualFolder, name string) vfs.BaseVirtualFolder { + folder.Name = name + replacements := make(map[string]string) + replacements["%name%"] = folder.Name + + folder.MappedPath = replacePlaceholders(folder.MappedPath, replacements) + folder.Description = replacePlaceholders(folder.Description, replacements) + switch folder.FsConfig.Provider { + case vfs.CryptedFilesystemProvider: + folder.FsConfig.CryptConfig = getCryptFsFromTemplate(folder.FsConfig.CryptConfig, replacements) + case vfs.S3FilesystemProvider: + folder.FsConfig.S3Config = getS3FsFromTemplate(folder.FsConfig.S3Config, replacements) + case vfs.GCSFilesystemProvider: + folder.FsConfig.GCSConfig = getGCSFsFromTemplate(folder.FsConfig.GCSConfig, replacements) + case vfs.AzureBlobFilesystemProvider: + folder.FsConfig.AzBlobConfig = getAzBlobFsFromTemplate(folder.FsConfig.AzBlobConfig, replacements) + case vfs.SFTPFilesystemProvider: + folder.FsConfig.SFTPConfig = getSFTPFsFromTemplate(folder.FsConfig.SFTPConfig, replacements) + } + + return folder +} + +func getCryptFsFromTemplate(fsConfig vfs.CryptFsConfig, replacements map[string]string) vfs.CryptFsConfig { + if fsConfig.Passphrase != nil { + if fsConfig.Passphrase.IsPlain() { + payload := replacePlaceholders(fsConfig.Passphrase.GetPayload(), replacements) + fsConfig.Passphrase = kms.NewPlainSecret(payload) + } + } + return fsConfig +} + +func getS3FsFromTemplate(fsConfig vfs.S3FsConfig, replacements map[string]string) vfs.S3FsConfig { + fsConfig.KeyPrefix = replacePlaceholders(fsConfig.KeyPrefix, replacements) + fsConfig.AccessKey = replacePlaceholders(fsConfig.AccessKey, replacements) + if fsConfig.AccessSecret != nil && fsConfig.AccessSecret.IsPlain() { + payload := replacePlaceholders(fsConfig.AccessSecret.GetPayload(), replacements) + fsConfig.AccessSecret = kms.NewPlainSecret(payload) + } + return fsConfig +} + +func getGCSFsFromTemplate(fsConfig vfs.GCSFsConfig, replacements map[string]string) vfs.GCSFsConfig { + fsConfig.KeyPrefix = replacePlaceholders(fsConfig.KeyPrefix, replacements) + return fsConfig +} + +func getAzBlobFsFromTemplate(fsConfig vfs.AzBlobFsConfig, replacements map[string]string) vfs.AzBlobFsConfig { + fsConfig.KeyPrefix = replacePlaceholders(fsConfig.KeyPrefix, replacements) + fsConfig.AccountName = replacePlaceholders(fsConfig.AccountName, replacements) + if fsConfig.AccountKey != nil && fsConfig.AccountKey.IsPlain() { + payload := replacePlaceholders(fsConfig.AccountKey.GetPayload(), replacements) + fsConfig.AccountKey = kms.NewPlainSecret(payload) + } + return fsConfig +} + +func getSFTPFsFromTemplate(fsConfig vfs.SFTPFsConfig, replacements map[string]string) vfs.SFTPFsConfig { + fsConfig.Prefix = replacePlaceholders(fsConfig.Prefix, replacements) + fsConfig.Username = replacePlaceholders(fsConfig.Username, replacements) + if fsConfig.Password != nil && fsConfig.Password.IsPlain() { + payload := replacePlaceholders(fsConfig.Password.GetPayload(), replacements) + fsConfig.Password = kms.NewPlainSecret(payload) + } + return fsConfig +} + +func getUserFromTemplate(user dataprovider.User, template userTemplateFields) dataprovider.User { + user.Username = template.Username + user.Password = template.Password + user.PublicKeys = nil + if template.PublicKey != "" { + user.PublicKeys = append(user.PublicKeys, template.PublicKey) + } + replacements := make(map[string]string) + replacements["%username%"] = user.Username + user.Password = replacePlaceholders(user.Password, replacements) + replacements["%password%"] = user.Password + + user.HomeDir = replacePlaceholders(user.HomeDir, replacements) + var vfolders []vfs.VirtualFolder + for _, vfolder := range user.VirtualFolders { + vfolder.Name = replacePlaceholders(vfolder.Name, replacements) + vfolder.VirtualPath = replacePlaceholders(vfolder.VirtualPath, replacements) + vfolders = append(vfolders, vfolder) + } + user.VirtualFolders = vfolders + user.Description = replacePlaceholders(user.Description, replacements) + user.AdditionalInfo = replacePlaceholders(user.AdditionalInfo, replacements) + + switch user.FsConfig.Provider { + case vfs.CryptedFilesystemProvider: + user.FsConfig.CryptConfig = getCryptFsFromTemplate(user.FsConfig.CryptConfig, replacements) + case vfs.S3FilesystemProvider: + user.FsConfig.S3Config = getS3FsFromTemplate(user.FsConfig.S3Config, replacements) + case vfs.GCSFilesystemProvider: + user.FsConfig.GCSConfig = getGCSFsFromTemplate(user.FsConfig.GCSConfig, replacements) + case vfs.AzureBlobFilesystemProvider: + user.FsConfig.AzBlobConfig = getAzBlobFsFromTemplate(user.FsConfig.AzBlobConfig, replacements) + case vfs.SFTPFilesystemProvider: + user.FsConfig.SFTPConfig = getSFTPFsFromTemplate(user.FsConfig.SFTPConfig, replacements) + } + + return user +} + +func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { + var user dataprovider.User + err := r.ParseMultipartForm(maxRequestSize) + if err != nil { + return user, err + } + publicKeysFormValue := r.Form.Get("public_keys") + publicKeys := getSliceFromDelimitedValues(publicKeysFormValue, "\n") + uid, err := strconv.Atoi(r.Form.Get("uid")) + if err != nil { + return user, err + } + gid, err := strconv.Atoi(r.Form.Get("gid")) + if err != nil { + return user, err + } + maxSessions, err := strconv.Atoi(r.Form.Get("max_sessions")) + if err != nil { + return user, err + } + quotaSize, err := strconv.ParseInt(r.Form.Get("quota_size"), 10, 64) + if err != nil { + return user, err + } + quotaFiles, err := strconv.Atoi(r.Form.Get("quota_files")) + if err != nil { + return user, err + } + bandwidthUL, err := strconv.ParseInt(r.Form.Get("upload_bandwidth"), 10, 64) + if err != nil { + return user, err + } + bandwidthDL, err := strconv.ParseInt(r.Form.Get("download_bandwidth"), 10, 64) + if err != nil { + return user, err + } + status, err := strconv.Atoi(r.Form.Get("status")) + if err != nil { + return user, err + } + expirationDateMillis := int64(0) + expirationDateString := r.Form.Get("expiration_date") + if len(strings.TrimSpace(expirationDateString)) > 0 { + expirationDate, err := time.Parse(webDateTimeFormat, expirationDateString) + if err != nil { + return user, err + } + expirationDateMillis = utils.GetTimeAsMsSinceEpoch(expirationDate) + } + fsConfig, err := getFsConfigFromPostFields(r) + if err != nil { + return user, err + } + user = dataprovider.User{ + Username: r.Form.Get("username"), + Password: r.Form.Get("password"), + PublicKeys: publicKeys, + HomeDir: r.Form.Get("home_dir"), + VirtualFolders: getVirtualFoldersFromPostFields(r), + UID: uid, + GID: gid, + Permissions: getUserPermissionsFromPostFields(r), + MaxSessions: maxSessions, + QuotaSize: quotaSize, + QuotaFiles: quotaFiles, + UploadBandwidth: bandwidthUL, + DownloadBandwidth: bandwidthDL, + Status: status, + ExpirationDate: expirationDateMillis, + Filters: getFiltersFromUserPostFields(r), + FsConfig: fsConfig, + AdditionalInfo: r.Form.Get("additional_info"), + Description: r.Form.Get("description"), + } + maxFileSize, err := strconv.ParseInt(r.Form.Get("max_upload_file_size"), 10, 64) + user.Filters.MaxUploadFileSize = maxFileSize + return user, err +} + +func renderLoginPage(w http.ResponseWriter, error string) { + data := loginPage{ + CurrentURL: webLoginPath, + Version: version.Get().Version, + Error: error, + CSRFToken: createCSRFToken(), + StaticURL: webStaticFilesPath, + } + renderAdminTemplate(w, templateLogin, data) +} + +func handleWebAdminChangePwd(w http.ResponseWriter, r *http.Request) { + renderChangePwdPage(w, r, "") +} + +func handleWebAdminChangePwdPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + err := r.ParseForm() + if err != nil { + renderChangePwdPage(w, r, err.Error()) + return + } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } + err = doChangeAdminPassword(r, r.Form.Get("current_password"), r.Form.Get("new_password1"), + r.Form.Get("new_password2")) + if err != nil { + renderChangePwdPage(w, r, err.Error()) + return + } + handleWebLogout(w, r) +} + +func handleWebLogout(w http.ResponseWriter, r *http.Request) { + c := jwtTokenClaims{} + c.removeCookie(w, r) + + http.Redirect(w, r, webLoginPath, http.StatusFound) +} + +func handleWebLogin(w http.ResponseWriter, r *http.Request) { + renderLoginPage(w, "") +} + +func handleWebMaintenance(w http.ResponseWriter, r *http.Request) { + renderMaintenancePage(w, r, "") +} + +func handleWebRestore(w http.ResponseWriter, r *http.Request) { + err := r.ParseMultipartForm(MaxRestoreSize) + if err != nil { + renderMaintenancePage(w, r, err.Error()) + return + } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } + restoreMode, err := strconv.Atoi(r.Form.Get("mode")) + if err != nil { + renderMaintenancePage(w, r, err.Error()) + return + } + scanQuota, err := strconv.Atoi(r.Form.Get("quota")) + if err != nil { + renderMaintenancePage(w, r, err.Error()) + return + } + backupFile, _, err := r.FormFile("backup_file") + if err != nil { + renderMaintenancePage(w, r, err.Error()) + return + } + defer backupFile.Close() + + backupContent, err := io.ReadAll(backupFile) + if err != nil || len(backupContent) == 0 { + if len(backupContent) == 0 { + err = errors.New("backup file size must be greater than 0") + } + renderMaintenancePage(w, r, err.Error()) + return + } + + if err := restoreBackup(backupContent, "", scanQuota, restoreMode); err != nil { + renderMaintenancePage(w, r, err.Error()) + return + } + + renderMessagePage(w, r, "Data restored", "", http.StatusOK, nil, "Your backup was successfully restored") +} + +func handleGetWebAdmins(w http.ResponseWriter, r *http.Request) { + limit := defaultQueryLimit + if _, ok := r.URL.Query()["qlimit"]; ok { + var err error + limit, err = strconv.Atoi(r.URL.Query().Get("qlimit")) + if err != nil { + limit = defaultQueryLimit + } + } + admins := make([]dataprovider.Admin, 0, limit) + for { + a, err := dataprovider.GetAdmins(limit, len(admins), dataprovider.OrderASC) + if err != nil { + renderInternalServerErrorPage(w, r, err) + return + } + admins = append(admins, a...) + if len(a) < limit { + break + } + } + data := adminsPage{ + basePage: getBasePageData(pageAdminsTitle, webAdminsPath, r), + Admins: admins, + } + renderAdminTemplate(w, templateAdmins, data) +} + +func handleWebAddAdminGet(w http.ResponseWriter, r *http.Request) { + admin := &dataprovider.Admin{Status: 1} + renderAddUpdateAdminPage(w, r, admin, "", true) +} + +func handleWebUpdateAdminGet(w http.ResponseWriter, r *http.Request) { + username := getURLParam(r, "username") + admin, err := dataprovider.AdminExists(username) + if err == nil { + renderAddUpdateAdminPage(w, r, &admin, "", false) + } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { + renderNotFoundPage(w, r, err) + } else { + renderInternalServerErrorPage(w, r, err) + } +} + +func handleWebAddAdminPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + admin, err := getAdminFromPostFields(r) + if err != nil { + renderAddUpdateAdminPage(w, r, &admin, err.Error(), true) + return + } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } + err = dataprovider.AddAdmin(&admin) + if err != nil { + renderAddUpdateAdminPage(w, r, &admin, err.Error(), true) + return + } + http.Redirect(w, r, webAdminsPath, http.StatusSeeOther) +} + +func handleWebUpdateAdminPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + + username := getURLParam(r, "username") + admin, err := dataprovider.AdminExists(username) + if _, ok := err.(*dataprovider.RecordNotFoundError); ok { + renderNotFoundPage(w, r, err) + return + } else if err != nil { + renderInternalServerErrorPage(w, r, err) + return + } + + updatedAdmin, err := getAdminFromPostFields(r) + if err != nil { + renderAddUpdateAdminPage(w, r, &updatedAdmin, err.Error(), false) + return + } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } + updatedAdmin.ID = admin.ID + updatedAdmin.Username = admin.Username + if updatedAdmin.Password == "" { + updatedAdmin.Password = admin.Password + } + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + renderAddUpdateAdminPage(w, r, &updatedAdmin, fmt.Sprintf("Invalid token claims: %v", err), false) + return + } + if username == claims.Username { + if claims.isCriticalPermRemoved(updatedAdmin.Permissions) { + renderAddUpdateAdminPage(w, r, &updatedAdmin, "You cannot remove these permissions to yourself", false) + return + } + if updatedAdmin.Status == 0 { + renderAddUpdateAdminPage(w, r, &updatedAdmin, "You cannot disable yourself", false) + return + } + } + err = dataprovider.UpdateAdmin(&updatedAdmin) + if err != nil { + renderAddUpdateAdminPage(w, r, &admin, err.Error(), false) + return + } + http.Redirect(w, r, webAdminsPath, http.StatusSeeOther) +} + +func handleGetWebUsers(w http.ResponseWriter, r *http.Request) { + limit := defaultQueryLimit + if _, ok := r.URL.Query()["qlimit"]; ok { + var err error + limit, err = strconv.Atoi(r.URL.Query().Get("qlimit")) + if err != nil { + limit = defaultQueryLimit + } + } + users := make([]dataprovider.User, 0, limit) + for { + u, err := dataprovider.GetUsers(limit, len(users), dataprovider.OrderASC) + if err != nil { + renderInternalServerErrorPage(w, r, err) + return + } + users = append(users, u...) + if len(u) < limit { + break + } + } + data := usersPage{ + basePage: getBasePageData(pageUsersTitle, webUsersPath, r), + Users: users, + } + renderAdminTemplate(w, templateUsers, data) +} + +func handleWebTemplateFolderGet(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("from") != "" { + name := r.URL.Query().Get("from") + folder, err := dataprovider.GetFolderByName(name) + if err == nil { + renderFolderPage(w, r, folder, folderPageModeTemplate, "") + } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { + renderNotFoundPage(w, r, err) + } else { + renderInternalServerErrorPage(w, r, err) + } + } else { + folder := vfs.BaseVirtualFolder{} + renderFolderPage(w, r, folder, folderPageModeTemplate, "") + } +} + +func handleWebTemplateFolderPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + templateFolder := vfs.BaseVirtualFolder{} + err := r.ParseForm() + if err != nil { + renderMessagePage(w, r, "Error parsing folders fields", "", http.StatusBadRequest, err, "") + return + } + + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } + + templateFolder.MappedPath = r.Form.Get("mapped_path") + templateFolder.Description = r.Form.Get("description") + fsConfig, err := getFsConfigFromPostFields(r) + if err != nil { + renderMessagePage(w, r, "Error parsing folders fields", "", http.StatusBadRequest, err, "") + return + } + templateFolder.FsConfig = fsConfig + + var dump dataprovider.BackupData + dump.Version = dataprovider.DumpVersion + + foldersFields := getFoldersForTemplate(r) + for _, tmpl := range foldersFields { + f := getFolderFromTemplate(templateFolder, tmpl) + if err := dataprovider.ValidateFolder(&f); err != nil { + renderMessagePage(w, r, fmt.Sprintf("Error validating folder %#v", f.Name), "", http.StatusBadRequest, err, "") + return + } + dump.Folders = append(dump.Folders, f) + } + + if len(dump.Folders) == 0 { + renderMessagePage(w, r, "No folders to export", "No valid folders found, export is not possible", http.StatusBadRequest, nil, "") + return + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"sftpgo-%v-folders-from-template.json\"", len(dump.Folders))) + render.JSON(w, r, dump) +} + +func handleWebTemplateUserGet(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("from") != "" { + username := r.URL.Query().Get("from") + user, err := dataprovider.UserExists(username) + if err == nil { + user.SetEmptySecrets() + renderUserPage(w, r, &user, userPageModeTemplate, "") + } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { + renderNotFoundPage(w, r, err) + } else { + renderInternalServerErrorPage(w, r, err) + } + } else { + user := dataprovider.User{Status: 1} + renderUserPage(w, r, &user, userPageModeTemplate, "") + } +} + +func handleWebTemplateUserPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + templateUser, err := getUserFromPostFields(r) + if err != nil { + renderMessagePage(w, r, "Error parsing user fields", "", http.StatusBadRequest, err, "") + return + } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } + + var dump dataprovider.BackupData + dump.Version = dataprovider.DumpVersion + + userTmplFields := getUsersForTemplate(r) + for _, tmpl := range userTmplFields { + u := getUserFromTemplate(templateUser, tmpl) + if err := dataprovider.ValidateUser(&u); err != nil { + renderMessagePage(w, r, fmt.Sprintf("Error validating user %#v", u.Username), "", http.StatusBadRequest, err, "") + return + } + dump.Users = append(dump.Users, u) + for _, folder := range u.VirtualFolders { + if !dump.HasFolder(folder.Name) { + dump.Folders = append(dump.Folders, folder.BaseVirtualFolder) + } + } + } + + if len(dump.Users) == 0 { + renderMessagePage(w, r, "No users to export", "No valid users found, export is not possible", http.StatusBadRequest, nil, "") + return + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"sftpgo-%v-users-from-template.json\"", len(dump.Users))) + render.JSON(w, r, dump) +} + +func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("clone-from") != "" { + username := r.URL.Query().Get("clone-from") + user, err := dataprovider.UserExists(username) + if err == nil { + user.ID = 0 + user.Username = "" + user.Password = "" + user.SetEmptySecrets() + renderUserPage(w, r, &user, userPageModeAdd, "") + } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { + renderNotFoundPage(w, r, err) + } else { + renderInternalServerErrorPage(w, r, err) + } + } else { + user := dataprovider.User{Status: 1} + renderUserPage(w, r, &user, userPageModeAdd, "") + } +} + +func handleWebUpdateUserGet(w http.ResponseWriter, r *http.Request) { + username := getURLParam(r, "username") + user, err := dataprovider.UserExists(username) + if err == nil { + renderUserPage(w, r, &user, userPageModeUpdate, "") + } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { + renderNotFoundPage(w, r, err) + } else { + renderInternalServerErrorPage(w, r, err) + } +} + +func handleWebAddUserPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + user, err := getUserFromPostFields(r) + if err != nil { + renderUserPage(w, r, &user, userPageModeAdd, err.Error()) + return + } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } + err = dataprovider.AddUser(&user) + if err == nil { + http.Redirect(w, r, webUsersPath, http.StatusSeeOther) + } else { + renderUserPage(w, r, &user, userPageModeAdd, err.Error()) + } +} + +func handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + username := getURLParam(r, "username") + user, err := dataprovider.UserExists(username) + if _, ok := err.(*dataprovider.RecordNotFoundError); ok { + renderNotFoundPage(w, r, err) + return + } else if err != nil { + renderInternalServerErrorPage(w, r, err) + return + } + updatedUser, err := getUserFromPostFields(r) + if err != nil { + renderUserPage(w, r, &user, userPageModeUpdate, err.Error()) + return + } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } + updatedUser.ID = user.ID + updatedUser.Username = user.Username + updatedUser.SetEmptySecretsIfNil() + if updatedUser.Password == redactedSecret { + updatedUser.Password = user.Password + } + updateEncryptedSecrets(&updatedUser.FsConfig, user.FsConfig.S3Config.AccessSecret, user.FsConfig.AzBlobConfig.AccountKey, + user.FsConfig.GCSConfig.Credentials, user.FsConfig.CryptConfig.Passphrase, user.FsConfig.SFTPConfig.Password, + user.FsConfig.SFTPConfig.PrivateKey) + + err = dataprovider.UpdateUser(&updatedUser) + if err == nil { + if len(r.Form.Get("disconnect")) > 0 { + disconnectUser(user.Username) + } + http.Redirect(w, r, webUsersPath, http.StatusSeeOther) + } else { + renderUserPage(w, r, &user, userPageModeUpdate, err.Error()) + } +} + +func handleWebGetStatus(w http.ResponseWriter, r *http.Request) { + data := statusPage{ + basePage: getBasePageData(pageStatusTitle, webStatusPath, r), + Status: getServicesStatus(), + } + renderAdminTemplate(w, templateStatus, data) +} + +func handleWebGetConnections(w http.ResponseWriter, r *http.Request) { + connectionStats := common.Connections.GetStats() + data := connectionsPage{ + basePage: getBasePageData(pageConnectionsTitle, webConnectionsPath, r), + Connections: connectionStats, + } + renderAdminTemplate(w, templateConnections, data) +} + +func handleWebAddFolderGet(w http.ResponseWriter, r *http.Request) { + renderFolderPage(w, r, vfs.BaseVirtualFolder{}, folderPageModeAdd, "") +} + +func handleWebAddFolderPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + folder := vfs.BaseVirtualFolder{} + err := r.ParseMultipartForm(maxRequestSize) + if err != nil { + renderFolderPage(w, r, folder, folderPageModeAdd, err.Error()) + return + } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } + folder.MappedPath = r.Form.Get("mapped_path") + folder.Name = r.Form.Get("name") + folder.Description = r.Form.Get("description") + fsConfig, err := getFsConfigFromPostFields(r) + if err != nil { + renderFolderPage(w, r, folder, folderPageModeAdd, err.Error()) + return + } + folder.FsConfig = fsConfig + + err = dataprovider.AddFolder(&folder) + if err == nil { + http.Redirect(w, r, webFoldersPath, http.StatusSeeOther) + } else { + renderFolderPage(w, r, folder, folderPageModeAdd, err.Error()) + } +} + +func handleWebUpdateFolderGet(w http.ResponseWriter, r *http.Request) { + name := getURLParam(r, "name") + folder, err := dataprovider.GetFolderByName(name) + if err == nil { + renderFolderPage(w, r, folder, folderPageModeUpdate, "") + } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { + renderNotFoundPage(w, r, err) + } else { + renderInternalServerErrorPage(w, r, err) + } +} + +func handleWebUpdateFolderPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + name := getURLParam(r, "name") + folder, err := dataprovider.GetFolderByName(name) + if _, ok := err.(*dataprovider.RecordNotFoundError); ok { + renderNotFoundPage(w, r, err) + return + } else if err != nil { + renderInternalServerErrorPage(w, r, err) + return + } + + err = r.ParseMultipartForm(maxRequestSize) + if err != nil { + renderFolderPage(w, r, folder, folderPageModeUpdate, err.Error()) + return + } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderForbiddenPage(w, r, err.Error()) + return + } + fsConfig, err := getFsConfigFromPostFields(r) + if err != nil { + renderFolderPage(w, r, folder, folderPageModeUpdate, err.Error()) + return + } + updatedFolder := &vfs.BaseVirtualFolder{ + MappedPath: r.Form.Get("mapped_path"), + Description: r.Form.Get("description"), + } + updatedFolder.ID = folder.ID + updatedFolder.Name = folder.Name + updatedFolder.FsConfig = fsConfig + updatedFolder.FsConfig.SetEmptySecretsIfNil() + updateEncryptedSecrets(&updatedFolder.FsConfig, folder.FsConfig.S3Config.AccessSecret, folder.FsConfig.AzBlobConfig.AccountKey, + folder.FsConfig.GCSConfig.Credentials, folder.FsConfig.CryptConfig.Passphrase, folder.FsConfig.SFTPConfig.Password, + folder.FsConfig.SFTPConfig.PrivateKey) + + err = dataprovider.UpdateFolder(updatedFolder, folder.Users) + if err != nil { + renderFolderPage(w, r, folder, folderPageModeUpdate, err.Error()) + return + } + http.Redirect(w, r, webFoldersPath, http.StatusSeeOther) +} + +func handleWebGetFolders(w http.ResponseWriter, r *http.Request) { + limit := defaultQueryLimit + if _, ok := r.URL.Query()["qlimit"]; ok { + var err error + limit, err = strconv.Atoi(r.URL.Query().Get("qlimit")) + if err != nil { + limit = defaultQueryLimit + } + } + folders := make([]vfs.BaseVirtualFolder, 0, limit) + for { + f, err := dataprovider.GetFolders(limit, len(folders), dataprovider.OrderASC) + if err != nil { + renderInternalServerErrorPage(w, r, err) + return + } + folders = append(folders, f...) + if len(f) < limit { + break + } + } + + data := foldersPage{ + basePage: getBasePageData(pageFoldersTitle, webFoldersPath, r), + Folders: folders, + } + renderAdminTemplate(w, templateFolders, data) +} diff --git a/httpd/webclient.go b/httpd/webclient.go new file mode 100644 index 00000000..65e21297 --- /dev/null +++ b/httpd/webclient.go @@ -0,0 +1,656 @@ +package httpd + +import ( + "errors" + "fmt" + "html/template" + "io" + "mime" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/rs/xid" + + "github.com/drakkan/sftpgo/common" + "github.com/drakkan/sftpgo/dataprovider" + "github.com/drakkan/sftpgo/logger" + "github.com/drakkan/sftpgo/metrics" + "github.com/drakkan/sftpgo/utils" + "github.com/drakkan/sftpgo/version" + "github.com/drakkan/sftpgo/vfs" +) + +const ( + templateClientDir = "webclient" + templateClientBase = "base.html" + templateClientLogin = "login.html" + templateClientFiles = "files.html" + templateClientMessage = "message.html" + templateClientCredentials = "credentials.html" + pageClientFilesTitle = "My Files" + pageClientCredentialsTitle = "Credentials" +) + +// condResult is the result of an HTTP request precondition check. +// See https://tools.ietf.org/html/rfc7232 section 3. +type condResult int + +const ( + condNone condResult = iota + condTrue + condFalse +) + +var ( + clientTemplates = make(map[string]*template.Template) + unixEpochTime = time.Unix(0, 0) +) + +// isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0). +func isZeroTime(t time.Time) bool { + return t.IsZero() || t.Equal(unixEpochTime) +} + +type baseClientPage struct { + Title string + CurrentURL string + FilesURL string + CredentialsURL string + StaticURL string + LogoutURL string + FilesTitle string + CredentialsTitle string + Version string + CSRFToken string + LoggedUser *dataprovider.User +} + +type dirMapping struct { + DirName string + Href string +} + +type filesPage struct { + baseClientPage + CurrentDir string + Files []os.FileInfo + Error string + Paths []dirMapping + FormatTime func(time.Time) string + GetObjectURL func(string, string) string + GetSize func(int64) string + IsLink func(os.FileInfo) bool +} + +type clientMessagePage struct { + baseClientPage + Error string + Success string +} + +type credentialsPage struct { + baseClientPage + PublicKeys []string + ChangePwdURL string + ManageKeysURL string + PwdError string + KeyError string +} + +func getFileObjectURL(baseDir, name string) string { + return fmt.Sprintf("%v?path=%v", webClientFilesPath, url.QueryEscape(path.Join(baseDir, name))) +} + +func getFileObjectModTime(t time.Time) string { + if isZeroTime(t) { + return "" + } + return t.Format("2006-01-02 15:04") +} + +func isFileObjectLink(info os.FileInfo) bool { + return info.Mode()&os.ModeSymlink != 0 +} + +func loadClientTemplates(templatesPath string) { + filesPaths := []string{ + filepath.Join(templatesPath, templateClientDir, templateClientBase), + filepath.Join(templatesPath, templateClientDir, templateClientFiles), + } + credentialsPaths := []string{ + filepath.Join(templatesPath, templateClientDir, templateClientBase), + filepath.Join(templatesPath, templateClientDir, templateClientCredentials), + } + loginPath := []string{ + filepath.Join(templatesPath, templateClientDir, templateClientLogin), + } + messagePath := []string{ + filepath.Join(templatesPath, templateClientDir, templateClientBase), + filepath.Join(templatesPath, templateClientDir, templateClientMessage), + } + + filesTmpl := utils.LoadTemplate(template.ParseFiles(filesPaths...)) + credentialsTmpl := utils.LoadTemplate(template.ParseFiles(credentialsPaths...)) + loginTmpl := utils.LoadTemplate(template.ParseFiles(loginPath...)) + messageTmpl := utils.LoadTemplate(template.ParseFiles(messagePath...)) + + clientTemplates[templateClientFiles] = filesTmpl + clientTemplates[templateClientCredentials] = credentialsTmpl + clientTemplates[templateClientLogin] = loginTmpl + clientTemplates[templateClientMessage] = messageTmpl +} + +func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage { + var csrfToken string + if currentURL != "" { + csrfToken = createCSRFToken() + } + v := version.Get() + + return baseClientPage{ + Title: title, + CurrentURL: currentURL, + FilesURL: webClientFilesPath, + CredentialsURL: webClientCredentialsPath, + StaticURL: webStaticFilesPath, + LogoutURL: webClientLogoutPath, + FilesTitle: pageClientFilesTitle, + CredentialsTitle: pageClientCredentialsTitle, + Version: fmt.Sprintf("%v-%v", v.Version, v.CommitHash), + CSRFToken: csrfToken, + LoggedUser: getUserFromToken(r), + } +} + +func renderClientTemplate(w http.ResponseWriter, tmplName string, data interface{}) { + err := clientTemplates[tmplName].ExecuteTemplate(w, tmplName, data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func renderClientLoginPage(w http.ResponseWriter, error string) { + data := loginPage{ + CurrentURL: webClientLoginPath, + Version: version.Get().Version, + Error: error, + CSRFToken: createCSRFToken(), + StaticURL: webStaticFilesPath, + } + renderClientTemplate(w, templateClientLogin, data) +} + +func renderClientMessagePage(w http.ResponseWriter, r *http.Request, title, body string, statusCode int, err error, message string) { + var errorString string + if body != "" { + errorString = body + " " + } + if err != nil { + errorString += err.Error() + } + data := clientMessagePage{ + baseClientPage: getBaseClientPageData(title, "", r), + Error: errorString, + Success: message, + } + w.WriteHeader(statusCode) + renderClientTemplate(w, templateClientMessage, data) +} + +func renderClientInternalServerErrorPage(w http.ResponseWriter, r *http.Request, err error) { + renderClientMessagePage(w, r, page500Title, page500Body, http.StatusInternalServerError, err, "") +} + +func renderClientBadRequestPage(w http.ResponseWriter, r *http.Request, err error) { + renderClientMessagePage(w, r, page400Title, "", http.StatusBadRequest, err, "") +} + +func renderClientForbiddenPage(w http.ResponseWriter, r *http.Request, body string) { + renderClientMessagePage(w, r, page403Title, "", http.StatusForbidden, nil, body) +} + +func renderClientNotFoundPage(w http.ResponseWriter, r *http.Request, err error) { + renderClientMessagePage(w, r, page404Title, page404Body, http.StatusNotFound, err, "") +} + +func renderFilesPage(w http.ResponseWriter, r *http.Request, files []os.FileInfo, dirName, error string) { + data := filesPage{ + baseClientPage: getBaseClientPageData(pageClientFilesTitle, webClientFilesPath, r), + Files: files, + Error: error, + CurrentDir: dirName, + FormatTime: getFileObjectModTime, + GetObjectURL: getFileObjectURL, + GetSize: utils.ByteCountIEC, + IsLink: isFileObjectLink, + } + paths := []dirMapping{} + if dirName != "/" { + paths = append(paths, dirMapping{ + DirName: path.Base(dirName), + Href: "", + }) + for { + dirName = path.Dir(dirName) + if dirName == "/" || dirName == "." { + break + } + paths = append([]dirMapping{{ + DirName: path.Base(dirName), + Href: getFileObjectURL("/", dirName)}, + }, paths...) + } + } + data.Paths = paths + renderClientTemplate(w, templateClientFiles, data) +} + +func renderCredentialsPage(w http.ResponseWriter, r *http.Request, pwdError string, keyError string) { + data := credentialsPage{ + baseClientPage: getBaseClientPageData(pageClientCredentialsTitle, webClientCredentialsPath, r), + ChangePwdURL: webChangeClientPwdPath, + ManageKeysURL: webChangeClientKeysPath, + PwdError: pwdError, + KeyError: keyError, + } + user, err := dataprovider.UserExists(data.LoggedUser.Username) + if err != nil { + renderClientInternalServerErrorPage(w, r, err) + } + data.PublicKeys = user.PublicKeys + renderClientTemplate(w, templateClientCredentials, data) +} + +func handleClientWebLogin(w http.ResponseWriter, r *http.Request) { + renderClientLoginPage(w, "") +} + +func handleWebClientLogout(w http.ResponseWriter, r *http.Request) { + c := jwtTokenClaims{} + c.removeCookie(w, r) + + http.Redirect(w, r, webClientLoginPath, http.StatusFound) +} + +func handleClientGetFiles(w http.ResponseWriter, r *http.Request) { + common.Connections.AddNetworkConnection() + defer common.Connections.RemoveNetworkConnection() + + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + renderClientForbiddenPage(w, r, "Invalid token claims") + return + } + if !common.Connections.IsNewConnectionAllowed() { + logger.Log(logger.LevelDebug, common.ProtocolHTTP, "", "connection refused, configured limit reached") + renderClientForbiddenPage(w, r, "configured connections limit reached") + return + } + ipAddr := utils.GetIPFromRemoteAddress(r.RemoteAddr) + if common.IsBanned(ipAddr) { + renderClientForbiddenPage(w, r, "your IP address is banned") + return + } + + user, err := dataprovider.UserExists(claims.Username) + if err != nil { + renderClientInternalServerErrorPage(w, r, err) + return + } + + connID := xid.New().String() + connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID) + if err := checkWebClientUser(&user, r, connectionID); err != nil { + renderClientForbiddenPage(w, r, err.Error()) + return + } + connection := &Connection{ + BaseConnection: common.NewBaseConnection(connID, 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")) + } + var info os.FileInfo + if name == "/" { + info = vfs.NewFileInfo(name, true, 0, time.Now(), false) + } else { + info, err = connection.Stat(name, 0) + } + if err != nil { + renderFilesPage(w, r, nil, name, fmt.Sprintf("unable to stat file %#v: %v", name, err)) + return + } + if info.IsDir() { + renderDirContents(w, r, connection, name) + return + } + downloadFile(w, r, connection, name, info) +} + +func handleClientGetCredentials(w http.ResponseWriter, r *http.Request) { + renderCredentialsPage(w, r, "", "") +} + +func handleWebClientChangePwdPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + err := r.ParseForm() + if err != nil { + renderCredentialsPage(w, r, err.Error(), "") + return + } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderClientForbiddenPage(w, r, err.Error()) + return + } + err = doChangeUserPassword(r, r.Form.Get("current_password"), r.Form.Get("new_password1"), + r.Form.Get("new_password2")) + if err != nil { + renderCredentialsPage(w, r, err.Error(), "") + return + } + handleWebClientLogout(w, r) +} + +func handleWebClientManageKeysPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + err := r.ParseForm() + if err != nil { + renderCredentialsPage(w, r, "", err.Error()) + return + } + if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + renderClientForbiddenPage(w, r, err.Error()) + return + } + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + renderCredentialsPage(w, r, "", "Invalid token claims") + return + } + user, err := dataprovider.UserExists(claims.Username) + if err != nil { + renderCredentialsPage(w, r, "", err.Error()) + return + } + publicKeysFormValue := r.Form.Get("public_keys") + publicKeys := getSliceFromDelimitedValues(publicKeysFormValue, "\n") + user.PublicKeys = publicKeys + err = dataprovider.UpdateUser(&user) + if err != nil { + renderCredentialsPage(w, r, "", err.Error()) + return + } + renderClientMessagePage(w, r, "Public keys updated", "", http.StatusOK, nil, "Your public keys has been successfully updated") +} + +func doChangeUserPassword(r *http.Request, currentPassword, newPassword, confirmNewPassword string) error { + if currentPassword == "" || newPassword == "" || confirmNewPassword == "" { + return dataprovider.NewValidationError("please provide the current password and the new one two times") + } + if newPassword != confirmNewPassword { + return dataprovider.NewValidationError("the two password fields do not match") + } + if currentPassword == newPassword { + return dataprovider.NewValidationError("the new password must be different from the current one") + } + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + return errors.New("invalid token claims") + } + user, err := dataprovider.CheckUserAndPass(claims.Username, currentPassword, utils.GetIPFromRemoteAddress(r.RemoteAddr), + common.ProtocolHTTP) + if err != nil { + return dataprovider.NewValidationError("current password does not match") + } + user.Password = newPassword + + return dataprovider.UpdateUser(&user) +} + +func renderDirContents(w http.ResponseWriter, r *http.Request, connection *Connection, name string) { + contents, err := connection.ReadDir(name) + if err != nil { + renderFilesPage(w, r, nil, name, fmt.Sprintf("unable to get contents for directory %#v: %v", name, err)) + return + } + renderFilesPage(w, r, contents, name, "") +} + +func downloadFile(w http.ResponseWriter, r *http.Request, connection *Connection, name string, info os.FileInfo) { + var err error + rangeHeader := r.Header.Get("Range") + if rangeHeader != "" && checkIfRange(r, info.ModTime()) == condFalse { + rangeHeader = "" + } + offset := int64(0) + size := info.Size() + responseStatus := http.StatusOK + if strings.HasPrefix(rangeHeader, "bytes=") { + if strings.Contains(rangeHeader, ",") { + http.Error(w, fmt.Sprintf("unsupported range %#v", rangeHeader), http.StatusRequestedRangeNotSatisfiable) + return + } + offset, size, err = parseRangeRequest(rangeHeader[6:], size) + if err != nil { + http.Error(w, err.Error(), http.StatusRequestedRangeNotSatisfiable) + return + } + responseStatus = http.StatusPartialContent + } + reader, err := connection.getFileReader(name, offset) + if err != nil { + renderFilesPage(w, r, nil, name, fmt.Sprintf("unable to read file %#v: %v", name, err)) + return + } + defer reader.Close() + + w.Header().Set("Last-Modified", info.ModTime().UTC().Format(http.TimeFormat)) + if checkPreconditions(w, r, info.ModTime()) { + return + } + ctype := mime.TypeByExtension(path.Ext(name)) + if ctype == "" { + ctype = "application/octet-stream" + } + if responseStatus == http.StatusPartialContent { + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, info.Size())) + } + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + w.Header().Set("Content-Type", ctype) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%#v", path.Base(name))) + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(responseStatus) + if r.Method != http.MethodHead { + io.CopyN(w, reader, size) //nolint:errcheck + } +} + +func checkPreconditions(w http.ResponseWriter, r *http.Request, modtime time.Time) bool { + if checkIfUnmodifiedSince(r, modtime) == condFalse { + w.WriteHeader(http.StatusPreconditionFailed) + return true + } + if checkIfModifiedSince(r, modtime) == condFalse { + w.WriteHeader(http.StatusNotModified) + return true + } + return false +} + +func checkIfUnmodifiedSince(r *http.Request, modtime time.Time) condResult { + ius := r.Header.Get("If-Unmodified-Since") + if ius == "" || isZeroTime(modtime) { + return condNone + } + t, err := http.ParseTime(ius) + if err != nil { + return condNone + } + + // The Last-Modified header truncates sub-second precision so + // the modtime needs to be truncated too. + modtime = modtime.Truncate(time.Second) + if modtime.Before(t) || modtime.Equal(t) { + return condTrue + } + return condFalse +} + +func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + return condNone + } + ims := r.Header.Get("If-Modified-Since") + if ims == "" || isZeroTime(modtime) { + return condNone + } + t, err := http.ParseTime(ims) + if err != nil { + return condNone + } + // The Last-Modified header truncates sub-second precision so + // the modtime needs to be truncated too. + modtime = modtime.Truncate(time.Second) + if modtime.Before(t) || modtime.Equal(t) { + return condFalse + } + return condTrue +} + +func checkIfRange(r *http.Request, modtime time.Time) condResult { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + return condNone + } + ir := r.Header.Get("If-Range") + if ir == "" { + return condNone + } + if modtime.IsZero() { + return condFalse + } + t, err := http.ParseTime(ir) + if err != nil { + return condFalse + } + if modtime.Add(60 * time.Second).Before(t) { + return condTrue + } + return condFalse +} + +func parseRangeRequest(bytesRange string, size int64) (int64, int64, error) { + var start, end int64 + var err error + + values := strings.Split(bytesRange, "-") + if values[0] == "" { + start = -1 + } else { + start, err = strconv.ParseInt(values[0], 10, 64) + if err != nil { + return start, size, err + } + } + if len(values) >= 2 { + if values[1] != "" { + end, err = strconv.ParseInt(values[1], 10, 64) + if err != nil { + return start, size, err + } + if end >= size { + end = size - 1 + } + } + } + if start == -1 && end == 0 { + return 0, 0, fmt.Errorf("unsupported range %#v", bytesRange) + } + + if end > 0 { + if start == -1 { + // we have something like -500 + start = size - end + size = end + // this can't happen, we did end = size -1 above + /*if start < 0 { + return 0, 0, fmt.Errorf("unacceptable range %#v", bytesRange) + }*/ + } else { + // we have something like 500-600 + size = end - start + 1 + if size < 0 { + return 0, 0, fmt.Errorf("unacceptable range %#v", bytesRange) + } + } + return start, size, nil + } + /*if start == -1 { + return 0, 0, fmt.Errorf("unacceptable range %#v", bytesRange) + }*/ + // we have something like 500- + size -= start + if size < 0 { + return 0, 0, fmt.Errorf("unacceptable range %#v", bytesRange) + } + return start, size, err +} + +func updateLoginMetrics(user *dataprovider.User, ip string, err error) { + metrics.AddLoginAttempt(dataprovider.LoginMethodPassword) + if err != nil { + logger.ConnectionFailedLog(user.Username, ip, dataprovider.LoginMethodPassword, common.ProtocolHTTP, err.Error()) + event := common.HostEventLoginFailed + if _, ok := err.(*dataprovider.RecordNotFoundError); ok { + event = common.HostEventUserNotFound + } + common.AddDefenderEvent(ip, event) + } + metrics.AddLoginResult(dataprovider.LoginMethodPassword, err) + dataprovider.ExecutePostLoginHook(user, dataprovider.LoginMethodPassword, ip, common.ProtocolHTTP, err) +} + +func checkWebClientUser(user *dataprovider.User, r *http.Request, connectionID string) error { + if utils.IsStringInSlice(common.ProtocolHTTP, user.Filters.DeniedProtocols) { + logger.Debug(logSender, connectionID, "cannot login user %#v, protocol HTTP is not allowed", user.Username) + return fmt.Errorf("protocol HTTP is not allowed for user %#v", user.Username) + } + if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) { + logger.Debug(logSender, connectionID, "cannot login user %#v, password login method is not allowed", user.Username) + return fmt.Errorf("login method password is not allowed for user %#v", user.Username) + } + if user.MaxSessions > 0 { + activeSessions := common.Connections.GetActiveSessions(user.Username) + if activeSessions >= user.MaxSessions { + logger.Debug(logSender, connectionID, "authentication refused for user: %#v, too many open sessions: %v/%v", user.Username, + activeSessions, user.MaxSessions) + return fmt.Errorf("too many open sessions: %v", activeSessions) + } + } + if !user.IsLoginFromAddrAllowed(r.RemoteAddr) { + logger.Debug(logSender, connectionID, "cannot login user %#v, remote address is not allowed: %v", user.Username, r.RemoteAddr) + return fmt.Errorf("login for user %#v is not allowed from this address: %v", user.Username, r.RemoteAddr) + } + if connAddr, ok := r.Context().Value(connAddrKey).(string); ok { + if connAddr != r.RemoteAddr { + connIPAddr := utils.GetIPFromRemoteAddress(connAddr) + if common.IsBanned(connIPAddr) { + return errors.New("your IP address is banned") + } + if !user.IsLoginFromAddrAllowed(connIPAddr) { + return fmt.Errorf("login from IP %v is not allowed", connIPAddr) + } + } + } + return nil +} diff --git a/httpdtest/httpdtest.go b/httpdtest/httpdtest.go index 132096cb..0f3c241f 100644 --- a/httpdtest/httpdtest.go +++ b/httpdtest/httpdtest.go @@ -1168,6 +1168,11 @@ func compareUserFilterSubStructs(expected *dataprovider.User, actual *dataprovid return errors.New("denied protocols contents mismatch") } } + for _, options := range expected.Filters.WebClient { + if !utils.IsStringInSlice(options, actual.Filters.WebClient) { + return errors.New("web client options contents mismatch") + } + } if expected.Filters.Hooks.ExternalAuthDisabled != actual.Filters.Hooks.ExternalAuthDisabled { return errors.New("external_auth_disabled hook mismatch") } @@ -1202,6 +1207,9 @@ func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User) if expected.Filters.TLSUsername != actual.Filters.TLSUsername { return errors.New("TLSUsername mismatch") } + if len(expected.Filters.WebClient) != len(actual.Filters.WebClient) { + return errors.New("WebClient filter mismatch") + } if err := compareUserFilterSubStructs(expected, actual); err != nil { return err } diff --git a/pkgs/build.sh b/pkgs/build.sh index fa09a92d..9ca6c080 100755 --- a/pkgs/build.sh +++ b/pkgs/build.sh @@ -1,6 +1,6 @@ #!/bin/bash -NFPM_VERSION=2.5.0 +NFPM_VERSION=2.5.1 NFPM_ARCH=${NFPM_ARCH:-amd64} if [ -z ${SFTPGO_VERSION} ] then diff --git a/sftpgo.json b/sftpgo.json index 41844c41..2a9932f9 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -181,6 +181,7 @@ "port": 8080, "address": "127.0.0.1", "enable_web_admin": true, + "enable_web_client": true, "enable_https": false, "client_auth_type": 0, "tls_cipher_suites": [] @@ -189,7 +190,7 @@ "templates_path": "templates", "static_files_path": "static", "backups_path": "backups", - "web_admin_root": "", + "web_root": "", "certificate_file": "", "certificate_key_file": "", "ca_certificates": [], diff --git a/templates/admin.html b/templates/webadmin/admin.html similarity index 100% rename from templates/admin.html rename to templates/webadmin/admin.html diff --git a/templates/admins.html b/templates/webadmin/admins.html similarity index 96% rename from templates/admins.html rename to templates/webadmin/admins.html index f4821e07..f5adc940 100644 --- a/templates/admins.html +++ b/templates/webadmin/admins.html @@ -26,7 +26,7 @@
- +
@@ -161,7 +161,10 @@ }; var table = $('#dataTable').DataTable({ - "select": true, + "select": { + "style": "single", + "blurable": true + }, "stateSave": true, "stateDuration": 3600, "buttons": [], @@ -177,7 +180,7 @@ } ], "scrollX": false, - "scrollY": "50vh", + "scrollY": false, "responsive": true, "order": [[1, 'asc']] }); diff --git a/templates/base.html b/templates/webadmin/base.html similarity index 100% rename from templates/base.html rename to templates/webadmin/base.html diff --git a/templates/changepwd.html b/templates/webadmin/changepwd.html similarity index 100% rename from templates/changepwd.html rename to templates/webadmin/changepwd.html diff --git a/templates/connections.html b/templates/webadmin/connections.html similarity index 95% rename from templates/connections.html rename to templates/webadmin/connections.html index 121d6465..1eac34bd 100644 --- a/templates/connections.html +++ b/templates/webadmin/connections.html @@ -21,7 +21,7 @@
-
ID
+
@@ -133,8 +133,12 @@ }; var table = $('#dataTable').DataTable({ - "select": "true", + "select": { + "style": "single", + "blurable": true + }, "buttons": [], + "lengthChange": false, "columnDefs": [ { "targets": [0], @@ -156,13 +160,13 @@ {{if .LoggedAdmin.HasPermission "close_conns"}} table.button().add(0,'disconnect'); - table.buttons().container().appendTo('#dataTable_wrapper .col-md-6:eq(0)'); - table.on('select deselect', function () { var selectedRows = table.rows({ selected: true }).count(); table.button('disconnect:name').enable(selectedRows == 1); }); {{end}} + table.button().add(0,'pageLength'); + table.buttons().container().appendTo('#dataTable_wrapper .col-md-6:eq(0)'); }); diff --git a/templates/folder.html b/templates/webadmin/folder.html similarity index 100% rename from templates/folder.html rename to templates/webadmin/folder.html diff --git a/templates/folders.html b/templates/webadmin/folders.html similarity index 98% rename from templates/folders.html rename to templates/webadmin/folders.html index c8cd493f..fdc46f0d 100644 --- a/templates/folders.html +++ b/templates/webadmin/folders.html @@ -26,7 +26,7 @@
-
ID
+
@@ -220,7 +220,10 @@ function deleteAction() { }; var table = $('#dataTable').DataTable({ - "select": true, + "select": { + "style": "single", + "blurable": true + }, "stateSave": true, "stateDuration": 3600, "buttons": [], diff --git a/templates/fsconfig.html b/templates/webadmin/fsconfig.html similarity index 100% rename from templates/fsconfig.html rename to templates/webadmin/fsconfig.html diff --git a/templates/login.html b/templates/webadmin/login.html similarity index 100% rename from templates/login.html rename to templates/webadmin/login.html diff --git a/templates/maintenance.html b/templates/webadmin/maintenance.html similarity index 100% rename from templates/maintenance.html rename to templates/webadmin/maintenance.html diff --git a/templates/message.html b/templates/webadmin/message.html similarity index 100% rename from templates/message.html rename to templates/webadmin/message.html diff --git a/templates/status.html b/templates/webadmin/status.html similarity index 100% rename from templates/status.html rename to templates/webadmin/status.html diff --git a/templates/user.html b/templates/webadmin/user.html similarity index 97% rename from templates/user.html rename to templates/webadmin/user.html index 46769018..f3de6774 100644 --- a/templates/user.html +++ b/templates/webadmin/user.html @@ -176,6 +176,18 @@ +
+ +
+ +
+
+
diff --git a/templates/users.html b/templates/webadmin/users.html similarity index 98% rename from templates/users.html rename to templates/webadmin/users.html index 2299d43a..0b2156ff 100644 --- a/templates/users.html +++ b/templates/webadmin/users.html @@ -26,7 +26,7 @@
-
Name
+
@@ -240,7 +240,10 @@ }; var table = $('#dataTable').DataTable({ - "select": true, + "select": { + "style": "single", + "blurable": true + }, "stateSave": true, "stateDuration": 3600, "buttons": [], diff --git a/templates/webclient/base.html b/templates/webclient/base.html new file mode 100644 index 00000000..adbdd4a9 --- /dev/null +++ b/templates/webclient/base.html @@ -0,0 +1,217 @@ +{{define "base"}} + + + + + + + + + + + + SFTPGo WebClient - {{template "title" .}} + + + + + + + + + + + {{block "extra_css" .}}{{end}} + + + + + + +
+ + {{if .LoggedUser.Username}} + + + + {{end}} + + +
+ + +
+ + {{if .LoggedUser.Username}} + + + + {{end}} + + +
+ + {{template "page_body" .}} + +
+ + +
+ + {{if .LoggedUser.Username}} + +
+
+ +
+
+ + {{end}} + +
+ + +
+ + + + + + + + + + + {{block "dialog" .}}{{end}} + + + + + + + + + + + + + + + {{block "extra_js" .}}{{end}} + + + + +{{end}} \ No newline at end of file diff --git a/templates/webclient/credentials.html b/templates/webclient/credentials.html new file mode 100644 index 00000000..34763549 --- /dev/null +++ b/templates/webclient/credentials.html @@ -0,0 +1,74 @@ +{{template "base" .}} + +{{define "title"}}{{.Title}}{{end}} + +{{define "page_body"}} + +
+
+
Change password
+
+
+ {{if .PwdError}} +
+
{{.PwdError}}
+
+ {{end}} +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + + + +
+
+{{if .LoggedUser.CanManahePublicKeys}} +
+
+
Manage public keys
+
+
+ {{if .KeyError}} +
+
{{.KeyError}}
+
+ {{end}} +
+ +
+ +
+ + + One public key per line + +
+
+ + + + +
+
+{{end}} +{{end}} \ No newline at end of file diff --git a/templates/webclient/files.html b/templates/webclient/files.html new file mode 100644 index 00000000..22d810e8 --- /dev/null +++ b/templates/webclient/files.html @@ -0,0 +1,125 @@ +{{template "base" .}} + +{{define "title"}}{{.Title}}{{end}} + +{{define "extra_css"}} + + + + + +{{end}} + +{{define "page_body"}} + +
+
+
 Home {{range .Paths}}{{if eq .Href ""}}/{{.DirName}}{{else}}/{{.DirName}}{{end}}{{end}}
+
+
+ {{if .Error}} +
+
{{.Error}}
+
+ {{end}} +
+
ID
+ + + + + + + + + + {{range .Files}} + {{if .IsDir}} + + + + + + {{else}} + + + + + + + {{end}} + {{end}} + +
TypeNameSizeLast modified
1 {{.Name}}{{call $.FormatTime .ModTime}}
2 {{.Name}}{{if not (call $.IsLink .)}}{{call $.GetSize .Size}}{{end}}{{call $.FormatTime .ModTime}}
+
+
+ +{{end}} + +{{define "extra_js"}} + + + + + + + + + + +{{end}} \ No newline at end of file diff --git a/templates/webclient/login.html b/templates/webclient/login.html new file mode 100644 index 00000000..2d00503d --- /dev/null +++ b/templates/webclient/login.html @@ -0,0 +1,134 @@ + + + + + + + + + + + + SFTPGo - Login + + + + + + + + + + + +
+ + +
+ +
+ +
+
+ +
+
+
+
+

SFTPGo WebClient - {{.Version}}

+
+ {{if .Error}} +
+
{{.Error}}
+
+ {{end}} +
+
+ +
+
+ +
+ + +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/webclient/message.html b/templates/webclient/message.html new file mode 100644 index 00000000..8c408e64 --- /dev/null +++ b/templates/webclient/message.html @@ -0,0 +1,59 @@ +{{template "base" .}} + +{{define "title"}}{{.Title}}{{end}} + +{{define "page_body"}} + +{{if .LoggedUser.Username}} +
+
+
{{.Title}}
+
+
+ {{if .Error}} +
+
{{.Error}}
+
+ {{end}} + + {{if .Success}} +
+
{{.Success}}
+
+ {{end}} +
+
+{{else}} +
+ +
+ +
+
+
+
+
+
+

{{.Title}}

+
+ {{if .Error}} +
+
{{.Error}}
+
+ {{end}} + + {{if .Success}} +
+
{{.Success}}
+
+ {{end}} +
+
+
+
+
+
+
+{{end}} + +{{end}} \ No newline at end of file diff --git a/utils/utils.go b/utils/utils.go index 57ef2fe5..35e67906 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -40,8 +40,8 @@ const ( // IsStringInSlice searches a string in a slice and returns true if the string is found func IsStringInSlice(obj string, list []string) bool { - for _, v := range list { - if v == obj { + for i := 0; i < len(list); i++ { + if list[i] == obj { return true } } @@ -51,8 +51,8 @@ func IsStringInSlice(obj string, list []string) bool { // IsStringPrefixInSlice searches a string prefix in a slice and returns true // if a matching prefix is found func IsStringPrefixInSlice(obj string, list []string) bool { - for _, v := range list { - if strings.HasPrefix(obj, v) { + for i := 0; i < len(list); i++ { + if strings.HasPrefix(obj, list[i]) { return true } } diff --git a/vfs/cryptfs.go b/vfs/cryptfs.go index c614a92e..5c8d4d4b 100644 --- a/vfs/cryptfs.go +++ b/vfs/cryptfs.go @@ -96,6 +96,7 @@ func (fs *CryptFs) Open(name string, offset int64) (File, *pipeat.PipeReaderAt, finished := false for !finished { readed, err = readerAt.ReadAt(buf, offset) + offset += int64(readed) if err != nil && err != io.EOF { break } diff --git a/vfs/osfs.go b/vfs/osfs.go index 0d548cd1..de583009 100644 --- a/vfs/osfs.go +++ b/vfs/osfs.go @@ -64,6 +64,16 @@ func (fs *OsFs) Lstat(name string) (os.FileInfo, error) { // Open opens the named file for reading func (*OsFs) Open(name string, offset int64) (File, *pipeat.PipeReaderAt, func(), error) { f, err := os.Open(name) + if err != nil { + return nil, nil, nil, err + } + if offset > 0 { + _, err = f.Seek(offset, io.SeekStart) + if err != nil { + f.Close() + return nil, nil, nil, err + } + } return f, nil, nil, err } diff --git a/webdavd/file.go b/webdavd/file.go index f0f4c930..66db06d3 100644 --- a/webdavd/file.go +++ b/webdavd/file.go @@ -323,6 +323,7 @@ func (f *webDavFile) closeIO() error { func (f *webDavFile) setFinished() error { f.Lock() defer f.Unlock() + if f.isFinished { return common.ErrTransferClosed } diff --git a/webdavd/server.go b/webdavd/server.go index b5854ddf..ee9d93b7 100644 --- a/webdavd/server.go +++ b/webdavd/server.go @@ -151,7 +151,7 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer common.Connections.RemoveNetworkConnection() if !common.Connections.IsNewConnectionAllowed() { - logger.Log(logger.LevelDebug, common.ProtocolFTP, "", "connection refused, configured limit reached") + logger.Log(logger.LevelDebug, common.ProtocolWebDAV, "", "connection refused, configured limit reached") http.Error(w, common.ErrConnectionDenied.Error(), http.StatusServiceUnavailable) return } @@ -281,7 +281,7 @@ func (s *webDavServer) authenticate(r *http.Request, ip string) (dataprovider.Us if err != nil { user.Username = username updateLoginMetrics(&user, ip, loginMethod, err) - return user, false, nil, loginMethod, err + return user, false, nil, loginMethod, dataprovider.ErrInvalidCredentials } lockSystem := webdav.NewMemLS() cachedUser = &dataprovider.CachedUser{ diff --git a/webdavd/webdavd_test.go b/webdavd/webdavd_test.go index ab7c8095..24026b9a 100644 --- a/webdavd/webdavd_test.go +++ b/webdavd/webdavd_test.go @@ -1979,7 +1979,7 @@ func TestWrongClientCertificate(t *testing.T) { body, err := io.ReadAll(resp.Body) assert.NoError(t, err) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, string(body)) - assert.Contains(t, string(body), "CN \"client1\" does not match username \"client2\"") + assert.Contains(t, string(body), "invalid credentials") } _, err = httpdtest.RemoveUser(user, http.StatusOK) diff --git a/windows-installer/sftpgo.iss b/windows-installer/sftpgo.iss index aede10b8..c784a373 100644 --- a/windows-installer/sftpgo.iss +++ b/windows-installer/sftpgo.iss @@ -53,7 +53,8 @@ Name: "{commonappdata}\{#MyAppName}\backups"; Permissions: everyone-full Name: "{commonappdata}\{#MyAppName}\credentials"; Permissions: everyone-full [Icons] -Name: "{group}\Web Admin"; Filename: "http://127.0.0.1:8080/web"; +Name: "{group}\Web Admin"; Filename: "http://127.0.0.1:8080/web/admin"; +Name: "{group}\Web Client"; Filename: "http://127.0.0.1:8080/web/client"; Name: "{group}\Service Control"; WorkingDir: "{app}"; Filename: "powershell.exe"; Parameters: "-Command ""Start-Process cmd \""/k cd {app} & {#MyAppExeName} service --help\"" -Verb RunAs"; Comment: "Manage SFTPGo Service" Name: "{group}\Documentation"; Filename: "{#DocURL}"; Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"