瀏覽代碼

Add a link on the login pages to switch between admin and web client login

The links are hidden if only the web admin or only thw web client is
enabled and can also be controlled using the "hide_login_url" setting

Fixes #485
Nicola Murino 4 年之前
父節點
當前提交
90b324d707

+ 9 - 2
config/config.go

@@ -72,6 +72,7 @@ var (
 		ClientAuthType:  0,
 		TLSCipherSuites: nil,
 		ProxyAllowed:    nil,
+		HideLoginURL:    0,
 	}
 	defaultRateLimiter = common.RateLimiterConfig{
 		Average:                0,
@@ -876,6 +877,12 @@ func getHTTPDBindingFromEnv(idx int) {
 		isSet = true
 	}
 
+	hideLoginURL, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__HIDE_LOGIN_URL", idx))
+	if ok {
+		binding.HideLoginURL = int(hideLoginURL)
+		isSet = true
+	}
+
 	if isSet {
 		if len(globalConf.HTTPDConfig.Bindings) > idx {
 			globalConf.HTTPDConfig.Bindings[idx] = binding
@@ -1062,7 +1069,7 @@ func setViperDefaults() {
 func lookupBoolFromEnv(envName string) (bool, bool) {
 	value, ok := os.LookupEnv(envName)
 	if ok {
-		converted, err := strconv.ParseBool(value)
+		converted, err := strconv.ParseBool(strings.TrimSpace(value))
 		if err == nil {
 			return converted, ok
 		}
@@ -1074,7 +1081,7 @@ func lookupBoolFromEnv(envName string) (bool, bool) {
 func lookupIntFromEnv(envName string) (int64, bool) {
 	value, ok := os.LookupEnv(envName)
 	if ok {
-		converted, err := strconv.ParseInt(value, 10, 64)
+		converted, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
 		if err == nil {
 			return converted, ok
 		}

+ 8 - 1
config/config_test.go

@@ -584,14 +584,16 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__1__ADDRESS", "127.0.0.1")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__1__PORT", "8000")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_HTTPS", "0")
+	os.Setenv("SFTPGO_HTTPD__BINDINGS__1__HIDE_LOGIN_URL", " 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__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")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__PROXY_ALLOWED", " 192.168.9.1 , 172.16.25.0/24")
+	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__HIDE_LOGIN_URL", "3")
 	t.Cleanup(func() {
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__0__ADDRESS")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__0__PORT")
@@ -599,6 +601,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__ADDRESS")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__PORT")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_HTTPS")
+		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__HIDE_LOGIN_URL")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ADDRESS")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__PORT")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_HTTPS")
@@ -607,6 +610,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_AUTH_TYPE")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__PROXY_ALLOWED")
+		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__HIDE_LOGIN_URL")
 	})
 
 	configDir := ".."
@@ -621,12 +625,14 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
 	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, 0, bindings[0].HideLoginURL)
 	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, 1, bindings[1].HideLoginURL)
 
 	require.Equal(t, 9000, bindings[2].Port)
 	require.Equal(t, "127.0.1.1", bindings[2].Address)
@@ -640,6 +646,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
 	require.Len(t, bindings[2].ProxyAllowed, 2)
 	require.Equal(t, "192.168.9.1", bindings[2].ProxyAllowed[0])
 	require.Equal(t, "172.16.25.0/24", bindings[2].ProxyAllowed[1])
+	require.Equal(t, 3, bindings[2].HideLoginURL)
 }
 
 func TestHTTPClientCertificatesFromEnv(t *testing.T) {

+ 1 - 0
docs/full-configuration.md

@@ -204,6 +204,7 @@ The configuration file contains the following sections:
     - `client_auth_type`, integer. Set to `1` to require client certificate authentication in addition to JWT/Web authentication. You need to define at least a certificate authority for this to work. Default: 0.
     - `tls_cipher_suites`, list of strings. List of supported cipher suites for TLS version 1.2. If empty, a default list of secure cipher suites is used, with a preference order based on hardware performance. Note that TLS 1.3 ciphersuites are not configurable. The supported ciphersuites names are defined [here](https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L52). Any invalid name will be silently ignored. The order matters, the ciphers listed first will be the preferred ones. Default: empty.
     - `proxy_allowed`, list of IP addresses and IP ranges allowed to set `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto`, `CF-Connecting-IP`, `True-Client-IP` headers. Any of the indicated headers, if set on requests from a connection address not in this list, will be silently ignored. Default: empty.
+    - `hide_login_url`, integer. If both web admin and web client are enabled each login page will show a link to the other one. This setting allows to hide this link. 0 means that the login links are displayed on both admin and client login page. This is the default. 1 means that the login link to the web client login page is hidden on admin login page. 2 means that the login link to the web admin login page is hidden on client login page. The flags can be combined, for example 3 will disable both login links.
   - `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

+ 28 - 1
httpd/httpd.go

@@ -184,7 +184,14 @@ type Binding struct {
 	TLSCipherSuites []string `json:"tls_cipher_suites" mapstructure:"tls_cipher_suites"`
 	// List of IP addresses and IP ranges allowed to set X-Forwarded-For, X-Real-IP,
 	// X-Forwarded-Proto headers.
-	ProxyAllowed     []string `json:"proxy_allowed" mapstructure:"proxy_allowed"`
+	ProxyAllowed []string `json:"proxy_allowed" mapstructure:"proxy_allowed"`
+	// If both web admin and web client are enabled each login page will show a link
+	// to the other one. This setting allows to hide this link:
+	// - 0 login links are displayed on both admin and client login page. This is the default
+	// - 1 the login link to the web client login page is hidden on admin login page
+	// - 2 the login link to the web admin login page is hidden on client login page
+	// The flags can be combined, for example 3 will disable both login links.
+	HideLoginURL     int `json:"hide_login_url" mapstructure:"hide_login_url"`
 	allowHeadersFrom []func(net.IP) bool
 }
 
@@ -213,6 +220,26 @@ func (b *Binding) IsValid() bool {
 	return false
 }
 
+func (b *Binding) showAdminLoginURL() bool {
+	if !b.EnableWebAdmin {
+		return false
+	}
+	if b.HideLoginURL&2 != 0 {
+		return false
+	}
+	return true
+}
+
+func (b *Binding) showClientLoginURL() bool {
+	if !b.EnableWebClient {
+		return false
+	}
+	if b.HideLoginURL&1 != 0 {
+		return false
+	}
+	return true
+}
+
 type defenderStatus struct {
 	IsActive bool `json:"is_active"`
 }

+ 28 - 0
httpd/internal_test.go

@@ -1668,3 +1668,31 @@ func TestSigningKey(t *testing.T) {
 	_, err = server2.tokenAuth.Decode(accessToken)
 	assert.NoError(t, err)
 }
+
+func TestLoginLinks(t *testing.T) {
+	b := Binding{
+		EnableWebAdmin:  true,
+		EnableWebClient: false,
+	}
+	assert.False(t, b.showClientLoginURL())
+	b = Binding{
+		EnableWebAdmin:  false,
+		EnableWebClient: true,
+	}
+	assert.False(t, b.showAdminLoginURL())
+	b = Binding{
+		EnableWebAdmin:  true,
+		EnableWebClient: true,
+	}
+	assert.True(t, b.showAdminLoginURL())
+	assert.True(t, b.showClientLoginURL())
+	b.HideLoginURL = 3
+	assert.False(t, b.showAdminLoginURL())
+	assert.False(t, b.showClientLoginURL())
+	b.HideLoginURL = 1
+	assert.True(t, b.showAdminLoginURL())
+	assert.False(t, b.showClientLoginURL())
+	b.HideLoginURL = 2
+	assert.False(t, b.showAdminLoginURL())
+	assert.True(t, b.showClientLoginURL())
+}

+ 1 - 1
httpd/schema/openapi.yaml

@@ -2052,7 +2052,7 @@ paths:
     patch:
       tags:
         - users API
-      summary: Rename afile
+      summary: Rename a file
       description: Rename a file for the logged in user
       operationId: rename_user_file
       parameters:

+ 55 - 15
httpd/server.go

@@ -116,11 +116,29 @@ func (s *httpdServer) refreshCookie(next http.Handler) http.Handler {
 	})
 }
 
+func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, error string) {
+	data := loginPage{
+		CurrentURL: webClientLoginPath,
+		Version:    version.Get().Version,
+		Error:      error,
+		CSRFToken:  createCSRFToken(),
+		StaticURL:  webStaticFilesPath,
+	}
+	if s.binding.showAdminLoginURL() {
+		data.AltLoginURL = webLoginPath
+	}
+	renderClientTemplate(w, templateClientLogin, data)
+}
+
+func (s *httpdServer) handleClientWebLogin(w http.ResponseWriter, r *http.Request) {
+	s.renderClientLoginPage(w, "")
+}
+
 func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxLoginPostSize)
 
 	if err := r.ParseForm(); err != nil {
-		renderClientLoginPage(w, err.Error())
+		s.renderClientLoginPage(w, err.Error())
 		return
 	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
@@ -128,30 +146,30 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 	password := r.Form.Get("password")
 	if username == "" || password == "" {
 		updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, ipAddr, common.ErrNoCredentials)
-		renderClientLoginPage(w, "Invalid credentials")
+		s.renderClientLoginPage(w, "Invalid credentials")
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
 		updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, ipAddr, err)
-		renderClientLoginPage(w, err.Error())
+		s.renderClientLoginPage(w, err.Error())
 		return
 	}
 
 	if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolHTTP); err != nil {
-		renderClientLoginPage(w, fmt.Sprintf("access denied by post connect hook: %v", err))
+		s.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())
+		s.renderClientLoginPage(w, dataprovider.ErrInvalidCredentials.Error())
 		return
 	}
 	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String())
 	if err := checkHTTPClientUser(&user, r, connectionID); err != nil {
 		updateLoginMetrics(&user, ipAddr, err)
-		renderClientLoginPage(w, err.Error())
+		s.renderClientLoginPage(w, err.Error())
 		return
 	}
 
@@ -160,7 +178,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 	if err != nil {
 		logger.Warn(logSender, connectionID, "unable to check fs root: %v", err)
 		updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure)
-		renderClientLoginPage(w, err.Error())
+		s.renderClientLoginPage(w, err.Error())
 		return
 	}
 
@@ -174,7 +192,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 	if err != nil {
 		logger.Warn(logSender, connectionID, "unable to set client login cookie %v", err)
 		updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure)
-		renderClientLoginPage(w, err.Error())
+		s.renderClientLoginPage(w, err.Error())
 		return
 	}
 	updateLoginMetrics(&user, ipAddr, err)
@@ -185,27 +203,49 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 func (s *httpdServer) handleWebAdminLoginPost(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxLoginPostSize)
 	if err := r.ParseForm(); err != nil {
-		renderLoginPage(w, err.Error())
+		s.renderAdminLoginPage(w, err.Error())
 		return
 	}
 	username := r.Form.Get("username")
 	password := r.Form.Get("password")
 	if username == "" || password == "" {
-		renderLoginPage(w, "Invalid credentials")
+		s.renderAdminLoginPage(w, "Invalid credentials")
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
-		renderLoginPage(w, err.Error())
+		s.renderAdminLoginPage(w, err.Error())
 		return
 	}
 	admin, err := dataprovider.CheckAdminAndPass(username, password, util.GetIPFromRemoteAddress(r.RemoteAddr))
 	if err != nil {
-		renderLoginPage(w, err.Error())
+		s.renderAdminLoginPage(w, err.Error())
 		return
 	}
 	s.loginAdmin(w, r, &admin)
 }
 
+func (s *httpdServer) renderAdminLoginPage(w http.ResponseWriter, error string) {
+	data := loginPage{
+		CurrentURL: webLoginPath,
+		Version:    version.Get().Version,
+		Error:      error,
+		CSRFToken:  createCSRFToken(),
+		StaticURL:  webStaticFilesPath,
+	}
+	if s.binding.showClientLoginURL() {
+		data.AltLoginURL = webClientLoginPath
+	}
+	renderAdminTemplate(w, templateLogin, data)
+}
+
+func (s *httpdServer) handleWebAdminLogin(w http.ResponseWriter, r *http.Request) {
+	if !dataprovider.HasAdmin() {
+		http.Redirect(w, r, webAdminSetupPath, http.StatusFound)
+		return
+	}
+	s.renderAdminLoginPage(w, "")
+}
+
 func (s *httpdServer) handleWebAdminSetupPost(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxLoginPostSize)
 	if dataprovider.HasAdmin() {
@@ -260,7 +300,7 @@ func (s *httpdServer) loginAdmin(w http.ResponseWriter, r *http.Request, admin *
 	err := c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebAdmin)
 	if err != nil {
 		logger.Warn(logSender, "", "unable to set admin login cookie %v", err)
-		renderLoginPage(w, err.Error())
+		s.renderAdminLoginPage(w, err.Error())
 		return
 	}
 
@@ -672,7 +712,7 @@ func (s *httpdServer) initializeRouter() {
 		s.router.Get(webBaseClientPath, func(w http.ResponseWriter, r *http.Request) {
 			http.Redirect(w, r, webClientLoginPath, http.StatusMovedPermanently)
 		})
-		s.router.Get(webClientLoginPath, handleClientWebLogin)
+		s.router.Get(webClientLoginPath, s.handleClientWebLogin)
 		s.router.Post(webClientLoginPath, s.handleWebClientLoginPost)
 
 		s.router.Group(func(router chi.Router) {
@@ -706,7 +746,7 @@ func (s *httpdServer) initializeRouter() {
 		s.router.Get(webBaseAdminPath, func(w http.ResponseWriter, r *http.Request) {
 			s.redirectToWebPath(w, r, webLoginPath)
 		})
-		s.router.Get(webLoginPath, handleWebLogin)
+		s.router.Get(webLoginPath, s.handleWebAdminLogin)
 		s.router.Post(webLoginPath, s.handleWebAdminLoginPost)
 		s.router.Get(webAdminSetupPath, handleWebAdminSetupGet)
 		s.router.Post(webAdminSetupPath, s.handleWebAdminSetupPost)

+ 6 - 5
httpd/web.go

@@ -18,11 +18,12 @@ const (
 )
 
 type loginPage struct {
-	CurrentURL string
-	Version    string
-	Error      string
-	CSRFToken  string
-	StaticURL  string
+	CurrentURL  string
+	Version     string
+	Error       string
+	CSRFToken   string
+	StaticURL   string
+	AltLoginURL string
 }
 
 func getSliceFromDelimitedValues(values, delimiter string) []string {

+ 0 - 19
httpd/webadmin.go

@@ -1018,17 +1018,6 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 	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, "")
 }
@@ -1060,14 +1049,6 @@ func handleWebLogout(w http.ResponseWriter, r *http.Request) {
 	http.Redirect(w, r, webLoginPath, http.StatusFound)
 }
 
-func handleWebLogin(w http.ResponseWriter, r *http.Request) {
-	if !dataprovider.HasAdmin() {
-		http.Redirect(w, r, webAdminSetupPath, http.StatusFound)
-		return
-	}
-	renderLoginPage(w, "")
-}
-
 func handleWebMaintenance(w http.ResponseWriter, r *http.Request) {
 	renderMaintenancePage(w, r, "")
 }

+ 0 - 15
httpd/webclient.go

@@ -167,17 +167,6 @@ func renderClientTemplate(w http.ResponseWriter, tmplName string, data interface
 	}
 }
 
-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 != "" {
@@ -260,10 +249,6 @@ func renderCredentialsPage(w http.ResponseWriter, r *http.Request, pwdError stri
 	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, webBaseClientPath)

+ 2 - 1
sftpgo.json

@@ -191,7 +191,8 @@
         "enable_https": false,
         "client_auth_type": 0,
         "tls_cipher_suites": [],
-        "proxy_allowed": []
+        "proxy_allowed": [],
+        "hide_login_url": 0
       }
     ],
     "templates_path": "templates",

+ 7 - 1
templates/webadmin/login.html

@@ -9,7 +9,7 @@
     <meta name="description" content="">
     <meta name="author" content="">
 
-    <title>SFTPGo - Login</title>
+    <title>SFTPGo Admin - Login</title>
 
     <link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
 
@@ -110,6 +110,12 @@
                                             Login
                                         </button>
                                     </form>
+                                    {{if .AltLoginURL}}
+                                    <hr>
+                                    <div class="text-center">
+                                        <a class="small" href="{{.AltLoginURL}}">Web Client</a>
+                                    </div>
+                                    {{end}}
                                 </div>
                             </div>
                         </div>

+ 7 - 1
templates/webclient/login.html

@@ -9,7 +9,7 @@
     <meta name="description" content="">
     <meta name="author" content="">
 
-    <title>SFTPGo - Login</title>
+    <title>SFTPGo WebClient - Login</title>
 
     <link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
 
@@ -110,6 +110,12 @@
                                             Login
                                         </button>
                                     </form>
+                                    {{if .AltLoginURL}}
+                                    <hr>
+                                    <div class="text-center">
+                                        <a class="small" href="{{.AltLoginURL}}">Web Admin</a>
+                                    </div>
+                                    {{end}}
                                 </div>
                             </div>
                         </div>