diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index a2fab2fd50931..03ce10af3cb48 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1744,9 +1744,6 @@ LEVEL = Info ;; Session cookie name ;COOKIE_NAME = i_like_gitea ;; -;; If you use session in https only, default is false -;COOKIE_SECURE = false -;; ;; Session GC time interval in seconds, default is 86400 (1 day) ;GC_INTERVAL_TIME = 86400 ;; diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md index 7e8befb8b7e67..1d947999ec243 100644 --- a/docs/content/administration/config-cheat-sheet.en-us.md +++ b/docs/content/administration/config-cheat-sheet.en-us.md @@ -776,7 +776,6 @@ and - `PROVIDER`: **memory**: Session engine provider \[memory, file, redis, redis-cluster, db, mysql, couchbase, memcache, postgres\]. Setting `db` will reuse the configuration in `[database]` - `PROVIDER_CONFIG`: **data/sessions**: For file, the root path; for db, empty (database config will be used); for others, the connection string. Relative paths will be made absolute against _`AppWorkPath`_. -- `COOKIE_SECURE`: **false**: Enable this to force using HTTPS for all session access. - `COOKIE_NAME`: **i\_like\_gitea**: The name of the cookie used for the session ID. - `GC_INTERVAL_TIME`: **86400**: GC interval in seconds. - `SESSION_LIFE_TIME`: **86400**: Session life time in seconds, default is 86400 (1 day) diff --git a/docs/content/administration/config-cheat-sheet.zh-cn.md b/docs/content/administration/config-cheat-sheet.zh-cn.md index 328c269aac80d..2ae675f80c0f8 100644 --- a/docs/content/administration/config-cheat-sheet.zh-cn.md +++ b/docs/content/administration/config-cheat-sheet.zh-cn.md @@ -742,7 +742,6 @@ Gitea 创建以下非唯一队列: - `PROVIDER`: **memory**:会话存储引擎 \[memory, file, redis, redis-cluster, db, mysql, couchbase, memcache, postgres\]。设置为 `db` 将会重用 `[database]` 的配置信息。 - `PROVIDER_CONFIG`: **data/sessions**:对于文件,为根路径;对于 db,为空(将使用数据库配置);对于其他引擎,为连接字符串。相对路径将根据 _`AppWorkPath`_ 绝对化。 -- `COOKIE_SECURE`: **false**:启用此选项以强制在所有会话访问中使用 HTTPS。 - `COOKIE_NAME`: **i\_like\_gitea**:用于会话 ID 的 cookie 名称。 - `GC_INTERVAL_TIME`: **86400**:GC 间隔时间,以秒为单位。 - `SESSION_LIFE_TIME`: **86400**:会话生命周期,以秒为单位,默认为 86400(1 天)。 diff --git a/modules/context/context.go b/modules/context/context.go index 47ad310b095a2..0db022e3de84c 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -133,17 +133,6 @@ func NewWebContext(base *Base, render Render, session session.Store) *Context { // Contexter initializes a classic context for a request. func Contexter() func(next http.Handler) http.Handler { rnd := templates.HTMLRenderer() - csrfOpts := CsrfOptions{ - Secret: setting.SecretKey, - Cookie: setting.CSRFCookieName, - SetCookie: true, - Secure: setting.SessionConfig.Secure, - CookieHTTPOnly: setting.CSRFCookieHTTPOnly, - Header: "X-Csrf-Token", - CookieDomain: setting.SessionConfig.Domain, - CookiePath: setting.SessionConfig.CookiePath, - SameSite: setting.SessionConfig.SameSite, - } if !setting.IsProd { CsrfTokenRegenerationInterval = 5 * time.Second // in dev, re-generate the tokens more aggressively for debug purpose } @@ -166,6 +155,17 @@ func Contexter() func(next http.Handler) http.Handler { ctx.Base.AppendContextValue(WebContextKey, ctx) ctx.Base.AppendContextValueFunc(git.RepositoryContextKey, func() any { return ctx.Repo.GitRepo }) + csrfOpts := CsrfOptions{ + Secret: setting.SecretKey, + Cookie: setting.CSRFCookieName, + SetCookie: true, + Secure: middleware.GetCookieSecure(ctx.Req), + CookieHTTPOnly: setting.CSRFCookieHTTPOnly, + Header: "X-Csrf-Token", + CookieDomain: setting.SessionConfig.Domain, + CookiePath: setting.SessionConfig.CookiePath, + SameSite: setting.SessionConfig.SameSite, + } ctx.Csrf = PrepareCSRFProtector(csrfOpts, ctx) // Get the last flash message from cookie @@ -185,9 +185,9 @@ func Contexter() func(next http.Handler) http.Handler { // if there are new messages in the ctx.Flash, write them into cookie ctx.Resp.Before(func(resp ResponseWriter) { if val := ctx.Flash.Encode(); val != "" { - middleware.SetSiteCookie(ctx.Resp, CookieNameFlash, val, 0) + middleware.SetSiteCookie(ctx.Resp, ctx.Req, CookieNameFlash, val, 0) } else if lastFlashCookie != "" { - middleware.SetSiteCookie(ctx.Resp, CookieNameFlash, "", -1) + middleware.SetSiteCookie(ctx.Resp, ctx.Req, CookieNameFlash, "", -1) } }) diff --git a/modules/context/context_cookie.go b/modules/context/context_cookie.go index 9ce67a5298154..c0327e2116811 100644 --- a/modules/context/context_cookie.go +++ b/modules/context/context_cookie.go @@ -32,13 +32,13 @@ func removeSessionCookieHeader(w http.ResponseWriter) { // SetSiteCookie convenience function to set most cookies consistently // CSRF and a few others are the exception here func (ctx *Context) SetSiteCookie(name, value string, maxAge int) { - middleware.SetSiteCookie(ctx.Resp, name, value, maxAge) + middleware.SetSiteCookie(ctx.Resp, ctx.Req, name, value, maxAge) } // DeleteSiteCookie convenience function to delete most cookies consistently // CSRF and a few others are the exception here func (ctx *Context) DeleteSiteCookie(name string) { - middleware.SetSiteCookie(ctx.Resp, name, "", -1) + middleware.SetSiteCookie(ctx.Resp, ctx.Req, name, "", -1) } // GetSiteCookie returns given cookie value from request header. diff --git a/modules/setting/session.go b/modules/setting/session.go index d0bc938973ac1..ee498e1e1446a 100644 --- a/modules/setting/session.go +++ b/modules/setting/session.go @@ -27,8 +27,6 @@ var SessionConfig = struct { Gclifetime int64 // Max life time in seconds. Default is whatever GC interval time is. Maxlifetime int64 - // Use HTTPS only. Default is false. - Secure bool // Cookie domain name. Default is empty. Domain string // SameSite declares if your cookie should be restricted to a first-party or same-site context. Valid strings are "none", "lax", "strict". Default is "lax" @@ -50,7 +48,6 @@ func loadSessionFrom(rootCfg ConfigProvider) { } SessionConfig.CookieName = sec.Key("COOKIE_NAME").MustString("i_like_gitea") SessionConfig.CookiePath = AppSubURL + "/" // there was a bug, old code only set CookePath=AppSubURL, no trailing slash - SessionConfig.Secure = sec.Key("COOKIE_SECURE").MustBool(false) SessionConfig.Gclifetime = sec.Key("GC_INTERVAL_TIME").MustInt64(86400) SessionConfig.Maxlifetime = sec.Key("SESSION_LIFE_TIME").MustInt64(86400) SessionConfig.Domain = sec.Key("DOMAIN").String() diff --git a/modules/web/middleware/cookie.go b/modules/web/middleware/cookie.go index 621640895b95f..a13d470f2eca4 100644 --- a/modules/web/middleware/cookie.go +++ b/modules/web/middleware/cookie.go @@ -13,13 +13,13 @@ import ( ) // SetRedirectToCookie convenience function to set the RedirectTo cookie consistently -func SetRedirectToCookie(resp http.ResponseWriter, value string) { - SetSiteCookie(resp, "redirect_to", value, 0) +func SetRedirectToCookie(resp http.ResponseWriter, req *http.Request, value string) { + SetSiteCookie(resp, req, "redirect_to", value, 0) } // DeleteRedirectToCookie convenience function to delete most cookies consistently -func DeleteRedirectToCookie(resp http.ResponseWriter) { - SetSiteCookie(resp, "redirect_to", "", -1) +func DeleteRedirectToCookie(resp http.ResponseWriter, req *http.Request) { + SetSiteCookie(resp, req, "redirect_to", "", -1) } // GetSiteCookie returns given cookie value from request header. @@ -32,15 +32,24 @@ func GetSiteCookie(req *http.Request, name string) string { return val } +// GetCookieSecure returns whether the "Secure" attribute on a cookie should be set +func GetCookieSecure(req *http.Request) bool { + forwardedProto := strings.ToLower(req.Header.Get("x-forwarded-proto")) + if forwardedProto != "" { + return forwardedProto == "https" || forwardedProto == "wss" + } + return req.TLS != nil +} + // SetSiteCookie returns given cookie value from request header. -func SetSiteCookie(resp http.ResponseWriter, name, value string, maxAge int) { +func SetSiteCookie(resp http.ResponseWriter, req *http.Request, name, value string, maxAge int) { cookie := &http.Cookie{ Name: name, Value: url.QueryEscape(value), MaxAge: maxAge, Path: setting.SessionConfig.CookiePath, Domain: setting.SessionConfig.Domain, - Secure: setting.SessionConfig.Secure, + Secure: GetCookieSecure(req), HttpOnly: true, SameSite: setting.SessionConfig.SameSite, } diff --git a/modules/web/middleware/locale.go b/modules/web/middleware/locale.go index 34a16f04e7fac..d2da9244eef67 100644 --- a/modules/web/middleware/locale.go +++ b/modules/web/middleware/locale.go @@ -41,19 +41,19 @@ func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale { } if changeLang { - SetLocaleCookie(resp, lang, 1<<31-1) + SetLocaleCookie(resp, req, lang, 1<<31-1) } return translation.NewLocale(lang) } // SetLocaleCookie convenience function to set the locale cookie consistently -func SetLocaleCookie(resp http.ResponseWriter, lang string, maxAge int) { - SetSiteCookie(resp, "lang", lang, maxAge) +func SetLocaleCookie(resp http.ResponseWriter, req *http.Request, lang string, maxAge int) { + SetSiteCookie(resp, req, "lang", lang, maxAge) } // DeleteLocaleCookie convenience function to delete the locale cookie consistently // Setting the lang cookie will trigger the middleware to reset the language to previous state. -func DeleteLocaleCookie(resp http.ResponseWriter) { - SetSiteCookie(resp, "lang", "", -1) +func DeleteLocaleCookie(resp http.ResponseWriter, req *http.Request) { + SetSiteCookie(resp, req, "lang", "", -1) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 4f5f0383e9aa2..6408f2122bcbc 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3109,7 +3109,6 @@ config.provider_config = Provider Config config.cookie_name = Cookie Name config.gc_interval_time = GC Interval Time config.session_life_time = Session Life Time -config.https_only = HTTPS Only config.cookie_life_time = Cookie Life Time config.picture_config = Picture and Avatar Configuration diff --git a/routers/common/middleware.go b/routers/common/middleware.go index 8a39dda179893..4ad93c0c8951e 100644 --- a/routers/common/middleware.go +++ b/routers/common/middleware.go @@ -101,15 +101,20 @@ func stripSlashesMiddleware(next http.Handler) http.Handler { } func Sessioner() func(next http.Handler) http.Handler { - return session.Sessioner(session.Options{ - Provider: setting.SessionConfig.Provider, - ProviderConfig: setting.SessionConfig.ProviderConfig, - CookieName: setting.SessionConfig.CookieName, - CookiePath: setting.SessionConfig.CookiePath, - Gclifetime: setting.SessionConfig.Gclifetime, - Maxlifetime: setting.SessionConfig.Maxlifetime, - Secure: setting.SessionConfig.Secure, - SameSite: setting.SessionConfig.SameSite, - Domain: setting.SessionConfig.Domain, - }) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + handler := session.Sessioner(session.Options{ + Provider: setting.SessionConfig.Provider, + ProviderConfig: setting.SessionConfig.ProviderConfig, + CookieName: setting.SessionConfig.CookieName, + CookiePath: setting.SessionConfig.CookiePath, + Gclifetime: setting.SessionConfig.Gclifetime, + Maxlifetime: setting.SessionConfig.Maxlifetime, + Secure: middleware.GetCookieSecure(req), + SameSite: setting.SessionConfig.SameSite, + Domain: setting.SessionConfig.Domain, + }) + handler(next).ServeHTTP(resp, req) + }) + } } diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index c70a2d1c95125..f7ad076a41a5c 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -159,7 +159,6 @@ func Config(ctx *context.Context) { sessionCfg.CookiePath = realSession.CookiePath sessionCfg.Gclifetime = realSession.Gclifetime sessionCfg.Maxlifetime = realSession.Maxlifetime - sessionCfg.Secure = realSession.Secure sessionCfg.Domain = realSession.Domain } sessionCfg.ProviderConfig = shadowPassword(sessionCfg.Provider, sessionCfg.ProviderConfig) diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index c20a45ebc9721..1a74e1292b4d6 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -104,7 +104,7 @@ func resetLocale(ctx *context.Context, u *user_model.User) error { } } - middleware.SetLocaleCookie(ctx.Resp, u.Language, 0) + middleware.SetLocaleCookie(ctx.Resp, ctx.Req, u.Language, 0) if ctx.Locale.Language() != u.Language { ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) @@ -123,13 +123,13 @@ func checkAutoLogin(ctx *context.Context) bool { redirectTo := ctx.FormString("redirect_to") if len(redirectTo) > 0 { - middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + middleware.SetRedirectToCookie(ctx.Resp, ctx.Req, redirectTo) } else { redirectTo = ctx.GetSiteCookie("redirect_to") } if isSucceed { - middleware.DeleteRedirectToCookie(ctx.Resp) + middleware.DeleteRedirectToCookie(ctx.Resp, ctx.Req) ctx.RedirectToFirst(redirectTo, setting.AppSubURL+string(setting.LandingPageURL)) return true } @@ -323,7 +323,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe } } - middleware.SetLocaleCookie(ctx.Resp, u.Language, 0) + middleware.SetLocaleCookie(ctx.Resp, ctx.Req, u.Language, 0) if ctx.Locale.Language() != u.Language { ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) @@ -340,7 +340,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe } if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 && !utils.IsExternalURL(redirectTo) { - middleware.DeleteRedirectToCookie(ctx.Resp) + middleware.DeleteRedirectToCookie(ctx.Resp, ctx.Req) if obeyRedirect { ctx.RedirectToFirst(redirectTo) } @@ -371,7 +371,7 @@ func HandleSignOut(ctx *context.Context) { ctx.DeleteSiteCookie(setting.CookieUserName) ctx.DeleteSiteCookie(setting.CookieRememberName) ctx.Csrf.DeleteCookie(ctx) - middleware.DeleteRedirectToCookie(ctx.Resp) + middleware.DeleteRedirectToCookie(ctx.Resp, ctx.Req) } // SignOut sign out from login status @@ -400,7 +400,7 @@ func SignUp(ctx *context.Context) { redirectTo := ctx.FormString("redirect_to") if len(redirectTo) > 0 { - middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + middleware.SetRedirectToCookie(ctx.Resp, ctx.Req, redirectTo) } ctx.HTML(http.StatusOK, tplSignUp) @@ -735,7 +735,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) { ctx.Flash.Success(ctx.Tr("auth.account_activated")) if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { - middleware.DeleteRedirectToCookie(ctx.Resp) + middleware.DeleteRedirectToCookie(ctx.Resp, ctx.Req) ctx.RedirectToFirst(redirectTo) return } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 78dc84472a129..a9219443e95ef 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -855,7 +855,7 @@ func SignInOAuth(ctx *context.Context) { redirectTo := ctx.FormString("redirect_to") if len(redirectTo) > 0 { - middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + middleware.SetRedirectToCookie(ctx.Resp, ctx.Req, redirectTo) } // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user @@ -1163,7 +1163,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model } if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { - middleware.DeleteRedirectToCookie(ctx.Resp) + middleware.DeleteRedirectToCookie(ctx.Resp, ctx.Req) ctx.RedirectToFirst(redirectTo) return } diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go index 00fc17f098019..116f0dca968e4 100644 --- a/routers/web/auth/openid.go +++ b/routers/web/auth/openid.go @@ -45,13 +45,13 @@ func SignInOpenID(ctx *context.Context) { redirectTo := ctx.FormString("redirect_to") if len(redirectTo) > 0 { - middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + middleware.SetRedirectToCookie(ctx.Resp, ctx.Req, redirectTo) } else { redirectTo = ctx.GetSiteCookie("redirect_to") } if isSucceed { - middleware.DeleteRedirectToCookie(ctx.Resp) + middleware.DeleteRedirectToCookie(ctx.Resp, ctx.Req) ctx.RedirectToFirst(redirectTo) return } diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index 1432338e70832..f5eb9f4282b6a 100644 --- a/routers/web/auth/password.go +++ b/routers/web/auth/password.go @@ -338,7 +338,7 @@ func MustChangePasswordPost(ctx *context.Context) { log.Trace("User updated password: %s", u.Name) if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 && !utils.IsExternalURL(redirectTo) { - middleware.DeleteRedirectToCookie(ctx.Resp) + middleware.DeleteRedirectToCookie(ctx.Resp, ctx.Req) ctx.RedirectToFirst(redirectTo) return } diff --git a/routers/web/home.go b/routers/web/home.go index b94e3e9eb593d..bf47767568f52 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -41,7 +41,7 @@ func Home(ctx *context.Context) { } else if ctx.Doer.MustChangePassword { ctx.Data["Title"] = ctx.Tr("auth.must_change_password") ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password" - middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + middleware.SetRedirectToCookie(ctx.Resp, ctx.Req, setting.AppSubURL+ctx.Req.URL.RequestURI()) ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") } else { user.Dashboard(ctx) diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index 61089d0947b5f..5125a8b9009e5 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -407,7 +407,7 @@ func UpdateUserLang(ctx *context.Context) { } // Update the language to the one we just set - middleware.SetLocaleCookie(ctx.Resp, ctx.Doer.Language, 0) + middleware.SetLocaleCookie(ctx.Resp, ctx.Req, ctx.Doer.Language, 0) log.Trace("User settings updated: %s", ctx.Doer.Name) ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).Tr("settings.update_language_success")) diff --git a/services/auth/auth.go b/services/auth/auth.go index c7fdc56cbed07..880c65ba468c5 100644 --- a/services/auth/auth.go +++ b/services/auth/auth.go @@ -89,7 +89,7 @@ func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore } } - middleware.SetLocaleCookie(resp, user.Language, 0) + middleware.SetLocaleCookie(resp, req, user.Language, 0) // Clear whatever CSRF has right now, force to generate a new one if ctx := gitea_context.GetWebContext(req); ctx != nil { diff --git a/services/auth/middleware.go b/services/auth/middleware.go index 4a0b613fa662f..6455559154bef 100644 --- a/services/auth/middleware.go +++ b/services/auth/middleware.go @@ -108,7 +108,7 @@ func VerifyAuthWithOptions(options *VerifyOptions) func(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("auth.must_change_password") ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password" if ctx.Req.URL.Path != "/user/events" { - middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + middleware.SetRedirectToCookie(ctx.Resp, ctx.Req, setting.AppSubURL+ctx.Req.URL.RequestURI()) } ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") return @@ -136,7 +136,7 @@ func VerifyAuthWithOptions(options *VerifyOptions) func(ctx *context.Context) { if options.SignInRequired { if !ctx.IsSigned { if ctx.Req.URL.Path != "/user/events" { - middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + middleware.SetRedirectToCookie(ctx.Resp, ctx.Req, setting.AppSubURL+ctx.Req.URL.RequestURI()) } ctx.Redirect(setting.AppSubURL + "/user/login") return @@ -151,7 +151,7 @@ func VerifyAuthWithOptions(options *VerifyOptions) func(ctx *context.Context) { if !options.SignOutRequired && !ctx.IsSigned && len(ctx.GetSiteCookie(setting.CookieUserName)) > 0 { if ctx.Req.URL.Path != "/user/events" { - middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + middleware.SetRedirectToCookie(ctx.Resp, ctx.Req, setting.AppSubURL+ctx.Req.URL.RequestURI()) } ctx.Redirect(setting.AppSubURL + "/user/login") return diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl index 36d9bcb8a5e2d..cdaca7e830282 100644 --- a/templates/admin/config.tmpl +++ b/templates/admin/config.tmpl @@ -280,8 +280,6 @@
{{.SessionConfig.Gclifetime}} {{.locale.Tr "tool.raw_seconds"}}
{{.locale.Tr "admin.config.session_life_time"}}
{{.SessionConfig.Maxlifetime}} {{.locale.Tr "tool.raw_seconds"}}
-
{{.locale.Tr "admin.config.https_only"}}
-
{{if .SessionConfig.Secure}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}