From 61fe7c39a7989c05d705f9a0c48d5e953fbc2c15 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Tue, 12 Dec 2023 18:04:14 +0100 Subject: [PATCH] WebClient: allow to pass args for localized errors from the backend Signed-off-by: Nicola Murino --- go.mod | 12 +- go.sum | 24 +- internal/httpd/flash.go | 48 +++- internal/httpd/flash_test.go | 4 +- internal/httpd/internal_test.go | 11 +- internal/httpd/middleware.go | 6 +- internal/httpd/oidc.go | 37 +-- internal/httpd/server.go | 83 ++++--- internal/httpd/web.go | 2 +- internal/httpd/webadmin.go | 3 +- internal/httpd/webclient.go | 246 +++++++++++++------- internal/util/i18n.go | 59 ++++- static/locales/en/translation.json | 23 +- static/locales/it/translation.json | 23 +- templates/common/base.html | 7 +- templates/webclient/editfile.html | 6 +- templates/webclient/files.html | 6 +- templates/webclient/forgot-password.html | 2 +- templates/webclient/login.html | 2 +- templates/webclient/message.html | 2 +- templates/webclient/mfa.html | 22 +- templates/webclient/reset-password.html | 2 +- templates/webclient/sharelogin.html | 2 +- templates/webclient/shares.html | 4 +- templates/webclient/twofactor-recovery.html | 2 +- templates/webclient/twofactor.html | 2 +- 26 files changed, 433 insertions(+), 207 deletions(-) diff --git a/go.mod b/go.mod index cda01535..21b87551 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.21 require ( cloud.google.com/go/storage v1.35.1 - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.2.0 github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 github.com/alexedwards/argon2id v1.0.0 @@ -140,7 +140,7 @@ require ( github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/run v1.1.0 // indirect - github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.1.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect github.com/prometheus/client_model v0.5.0 // indirect @@ -169,10 +169,10 @@ require ( golang.org/x/tools v0.16.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto v0.0.0-20231127180814-3a041ad873d4 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect - google.golang.org/grpc v1.59.0 // indirect + google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20231211222908-989df2bf70f3 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231211222908-989df2bf70f3 // indirect + google.golang.org/grpc v1.60.0 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index dad59854..a5b08abe 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ cloud.google.com/go/kms v1.15.5/go.mod h1:cU2H5jnp6G2TDpUGZyqTCoy1n16fbubHZjmVXS cloud.google.com/go/storage v1.35.1 h1:B59ahL//eDfx2IIKFBeT5Atm9wnNmj3+8xG/W4WB//w= cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0 h1:fb8kj/Dh4CSwgsOzHeZY4Xh68cFVbzXx+ONXGMY//4w= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0/go.mod h1:uReU2sSxZExRPBAg3qKzmAucSi51+SP1OhohieR821Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1 h1:lGlwhPtrX6EVml1hO0ivjkUxsSyl4dsiw9qcA1k/3IQ= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0 h1:BMAjVKJM0U/CYF27gA0ZMmXGkOcvfFtD0oHVZ1TIPRI= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0/go.mod h1:1fXstnBMas5kzG+S3q8UoJcmyU6nUeunJcMDHcRYHhs= github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1 h1:6oNBlSdi1QqM1PNW7FPA6xOGA5UNsXnkaYZz9vdPGhA= @@ -302,8 +302,8 @@ github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks= github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs= github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= @@ -524,19 +524,19 @@ google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20231127180814-3a041ad873d4 h1:W12Pwm4urIbRdGhMEg2NM9O3TWKjNcxQhs46V0ypf/k= -google.golang.org/genproto v0.0.0-20231127180814-3a041ad873d4/go.mod h1:5RBcpGRxr25RbDzY5w+dmaqpSEvl8Gwl1x2CICf60ic= -google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4 h1:ZcOkrmX74HbKFYnpPY8Qsw93fC29TbJXspYKaBkSXDQ= -google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4/go.mod h1:k2dtGpRrbsSyKcNPKKI5sstZkrNCZwpU/ns96JoHbGg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 h1:DC7wcm+i+P1rN3Ff07vL+OndGg5OhNddHyTA+ocPqYE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM= +google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg= +google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3/go.mod h1:5RBcpGRxr25RbDzY5w+dmaqpSEvl8Gwl1x2CICf60ic= +google.golang.org/genproto/googleapis/api v0.0.0-20231211222908-989df2bf70f3 h1:EWIeHfGuUf00zrVZGEgYFxok7plSAXBGcH7NNdMAWvA= +google.golang.org/genproto/googleapis/api v0.0.0-20231211222908-989df2bf70f3/go.mod h1:k2dtGpRrbsSyKcNPKKI5sstZkrNCZwpU/ns96JoHbGg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231211222908-989df2bf70f3 h1:kzJAXnzZoFbe5bhZd4zjUuHos/I31yH4thfMb/13oVY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231211222908-989df2bf70f3/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= -google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/grpc v1.60.0 h1:6FQAR0kM31P6MRdeluor2w2gPaS4SVNrD/DNTxrQ15k= +google.golang.org/grpc v1.60.0/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/internal/httpd/flash.go b/internal/httpd/flash.go index 1185df16..e9b5e244 100644 --- a/internal/httpd/flash.go +++ b/internal/httpd/flash.go @@ -16,18 +16,47 @@ package httpd import ( "encoding/base64" + "encoding/json" "net/http" "time" + + "github.com/drakkan/sftpgo/v2/internal/util" ) const ( flashCookieName = "message" ) -func setFlashMessage(w http.ResponseWriter, r *http.Request, value string) { +func newFlashMessage(errorStrig, i18nMessage string) flashMessage { + return flashMessage{ + ErrorString: errorStrig, + I18nMessage: i18nMessage, + } +} + +type flashMessage struct { + ErrorString string `json:"error"` + I18nMessage string `json:"message"` +} + +func (m *flashMessage) getI18nError() *util.I18nError { + if m.ErrorString == "" && m.I18nMessage == "" { + return nil + } + return util.NewI18nError( + util.NewGenericError(m.ErrorString), + m.I18nMessage, + ) +} + +func setFlashMessage(w http.ResponseWriter, r *http.Request, message flashMessage) { + value, err := json.Marshal(message) + if err != nil { + return + } http.SetCookie(w, &http.Cookie{ Name: flashCookieName, - Value: base64.URLEncoding.EncodeToString([]byte(value)), + Value: base64.URLEncoding.EncodeToString(value), Path: "/", Expires: time.Now().Add(60 * time.Second), MaxAge: 60, @@ -38,10 +67,11 @@ func setFlashMessage(w http.ResponseWriter, r *http.Request, value string) { w.Header().Add("Cache-Control", `no-cache="Set-Cookie"`) } -func getFlashMessage(w http.ResponseWriter, r *http.Request) string { +func getFlashMessage(w http.ResponseWriter, r *http.Request) flashMessage { + var msg flashMessage cookie, err := r.Cookie(flashCookieName) if err != nil { - return "" + return msg } http.SetCookie(w, &http.Cookie{ Name: flashCookieName, @@ -53,9 +83,13 @@ func getFlashMessage(w http.ResponseWriter, r *http.Request) string { Secure: isTLS(r), SameSite: http.SameSiteLaxMode, }) - message, err := base64.URLEncoding.DecodeString(cookie.Value) + value, err := base64.URLEncoding.DecodeString(cookie.Value) if err != nil { - return "" + return msg } - return string(message) + err = json.Unmarshal(value, &msg) + if err != nil { + return flashMessage{} + } + return msg } diff --git a/internal/httpd/flash_test.go b/internal/httpd/flash_test.go index 38e91cc5..34c0fdbf 100644 --- a/internal/httpd/flash_test.go +++ b/internal/httpd/flash_test.go @@ -30,10 +30,10 @@ func TestFlashMessages(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "/url", nil) require.NoError(t, err) message := "test message" - setFlashMessage(rr, req, message) + setFlashMessage(rr, req, flashMessage{ErrorString: message}) req.Header.Set("Cookie", fmt.Sprintf("%v=%v", flashCookieName, base64.URLEncoding.EncodeToString([]byte(message)))) msg := getFlashMessage(rr, req) - assert.Equal(t, message, msg) + assert.Equal(t, message, msg.ErrorString) req.Header.Set("Cookie", fmt.Sprintf("%v=%v", flashCookieName, "a")) msg = getFlashMessage(rr, req) assert.Empty(t, msg) diff --git a/internal/httpd/internal_test.go b/internal/httpd/internal_test.go index 7af3971f..593b3f8c 100644 --- a/internal/httpd/internal_test.go +++ b/internal/httpd/internal_test.go @@ -3529,6 +3529,12 @@ func TestI18NErrors(t *testing.T) { assert.ErrorIs(t, errI18n, util.ErrValidation) assert.Equal(t, err.Error(), errI18n.Error()) assert.Equal(t, util.I18nError500Message, getI18NErrorString(errI18n, "")) + assert.Equal(t, util.I18nError500Message, errI18n.Message) + assert.Equal(t, "{}", errI18n.Args()) + var e1 *util.ValidationError + assert.ErrorAs(t, errI18n, &e1) + var e2 *util.I18nError + assert.ErrorAs(t, errI18n, &e2) err2 := util.NewI18nError(fs.ErrNotExist, util.I18nError500Message) assert.ErrorIs(t, err2, &util.I18nError{}) assert.ErrorIs(t, err2, fs.ErrNotExist) @@ -3537,7 +3543,10 @@ func TestI18NErrors(t *testing.T) { errorString := getI18NErrorString(nil, util.I18nError500Message) assert.Equal(t, util.I18nError500Message, errorString) errI18nWrap := util.NewI18nError(errI18n, util.I18nError404Message) - assert.Equal(t, util.I18nError500Message, errI18nWrap.I18nMessage) + assert.Equal(t, util.I18nError500Message, errI18nWrap.Message) + errI18n = util.NewI18nError(err, util.I18nError500Message, util.I18nErrorArgs(map[string]any{"a": "b"})) + assert.Equal(t, util.I18nError500Message, errI18n.Message) + assert.Equal(t, `{"a":"b"}`, errI18n.Args()) } func isSharedProviderSupported() bool { diff --git a/internal/httpd/middleware.go b/internal/httpd/middleware.go index 5cba7b67..8864b01f 100644 --- a/internal/httpd/middleware.go +++ b/internal/httpd/middleware.go @@ -233,11 +233,15 @@ func (s *httpdServer) checkAuthRequirements(next http.Handler) http.Handler { if tokenClaims.MustSetTwoFactorAuth || tokenClaims.MustChangePassword { var err error if tokenClaims.MustSetTwoFactorAuth { + protocols := strings.Join(tokenClaims.RequiredTwoFactorProtocols, ", ") err = util.NewI18nError( util.NewGenericError( fmt.Sprintf("Two-factor authentication requirements not met, please configure two-factor authentication for the following protocols: %v", - strings.Join(tokenClaims.RequiredTwoFactorProtocols, ", "))), + protocols)), util.I18nError2FARequired, + util.I18nErrorArgs(map[string]any{ + "val": protocols, + }), ) } else { err = util.NewI18nError( diff --git a/internal/httpd/oidc.go b/internal/httpd/oidc.go index 0bceae81..889be1e2 100644 --- a/internal/httpd/oidc.go +++ b/internal/httpd/oidc.go @@ -497,7 +497,7 @@ func (s *httpdServer) validateOIDCToken(w http.ResponseWriter, r *http.Request, defer cancel() if err = token.refresh(ctx, s.binding.OIDC.oauth2Config, s.binding.OIDC.getVerifier(ctx), r); err != nil { - setFlashMessage(w, r, "Your OpenID token is expired, please log-in again") + setFlashMessage(w, r, newFlashMessage("Your OpenID token is expired, please log-in again", util.I18nOIDCTokenExpired)) doRedirect() return oidcToken{}, errInvalidToken } @@ -507,7 +507,10 @@ func (s *httpdServer) validateOIDCToken(w http.ResponseWriter, r *http.Request, if isAdmin { if !token.isAdmin() { logger.Debug(logSender, "", "oidc token associated with cookie %q is not valid for admin users", token.Cookie) - setFlashMessage(w, r, "Your OpenID token is not valid for the SFTPGo Web Admin UI. Please logout from your OpenID server and log-in as an SFTPGo admin") + setFlashMessage(w, r, newFlashMessage( + "Your OpenID token is not valid for the SFTPGo Web Admin UI. Please logout from your OpenID server and log-in as an SFTPGo admin", + util.I18nOIDCTokenInvalidAdmin, + )) doRedirect() return oidcToken{}, errInvalidToken } @@ -515,7 +518,10 @@ func (s *httpdServer) validateOIDCToken(w http.ResponseWriter, r *http.Request, } if token.isAdmin() { logger.Debug(logSender, "", "oidc token associated with cookie %q is valid for admin users", token.Cookie) - setFlashMessage(w, r, "Your OpenID token is not valid for the SFTPGo Web Client UI. Please logout from your OpenID server and log-in as an SFTPGo user") + setFlashMessage(w, r, newFlashMessage( + "Your OpenID token is not valid for the SFTPGo Web Client UI. Please logout from your OpenID server and log-in as an SFTPGo user", + util.I18nOIDCTokenInvalidUser, + )) doRedirect() return oidcToken{}, errInvalidToken } @@ -541,7 +547,7 @@ func (s *httpdServer) oidcTokenAuthenticator(audience tokenAudience) func(next h } _, tokenString, err := jwtTokenClaims.createToken(s.tokenAuth, audience, util.GetIPFromRemoteAddress(r.RemoteAddr)) if err != nil { - setFlashMessage(w, r, "Unable to create cookie") + setFlashMessage(w, r, newFlashMessage("Unable to create cookie", util.I18nError500Message)) if audience == tokenAudienceWebAdmin { http.Redirect(w, r, webAdminLoginPath, http.StatusFound) } else { @@ -610,14 +616,14 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request) oauth2Token, err := s.binding.OIDC.oauth2Config.Exchange(ctx, r.URL.Query().Get("code")) if err != nil { logger.Debug(logSender, "", "failed to exchange oidc token: %v", err) - setFlashMessage(w, r, "Failed to exchange OpenID token") + setFlashMessage(w, r, newFlashMessage("Failed to exchange OpenID token", util.I18nOIDCErrTokenExchange)) doRedirect() return } rawIDToken, ok := oauth2Token.Extra("id_token").(string) if !ok { logger.Debug(logSender, "", "no id_token field in OAuth2 OpenID token") - setFlashMessage(w, r, "No id_token field in OAuth2 OpenID token") + setFlashMessage(w, r, newFlashMessage("No id_token field in OAuth2 OpenID token", util.I18nOIDCTokenInvalid)) doRedirect() return } @@ -625,14 +631,14 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request) idToken, err := s.binding.OIDC.getVerifier(ctx).Verify(ctx, rawIDToken) if err != nil { logger.Debug(logSender, "", "failed to verify oidc token: %v", err) - setFlashMessage(w, r, "Failed to verify OpenID token") + setFlashMessage(w, r, newFlashMessage("Failed to verify OpenID token", util.I18nOIDCTokenInvalid)) doRedirect() doLogout(rawIDToken) return } if idToken.Nonce != authReq.Nonce { logger.Debug(logSender, "", "oidc authentication nonce did not match") - setFlashMessage(w, r, "OpenID authentication nonce did not match") + setFlashMessage(w, r, newFlashMessage("OpenID authentication nonce did not match", util.I18nOIDCTokenInvalid)) doRedirect() doLogout(rawIDToken) return @@ -642,7 +648,7 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request) err = idToken.Claims(&claims) if err != nil { logger.Debug(logSender, "", "unable to get oidc token claims: %v", err) - setFlashMessage(w, r, "Unable to get OpenID token claims") + setFlashMessage(w, r, newFlashMessage("Unable to get OpenID token claims", util.I18nOIDCTokenInvalid)) doRedirect() doLogout(rawIDToken) return @@ -663,7 +669,7 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request) s.binding.OIDC.CustomFields, s.binding.OIDC.getForcedRole(authReq.Audience)) if err != nil { logger.Debug(logSender, "", "unable to parse oidc token claims: %v", err) - setFlashMessage(w, r, fmt.Sprintf("Unable to parse OpenID token claims: %v", err)) + setFlashMessage(w, r, newFlashMessage(fmt.Sprintf("Unable to parse OpenID token claims: %v", err), util.I18nOIDCTokenInvalid)) doRedirect() doLogout(rawIDToken) return @@ -672,7 +678,9 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request) case tokenAudienceWebAdmin: if !token.isAdmin() { logger.Debug(logSender, "", "wrong oidc token role, the mapped user is not an SFTPGo admin") - setFlashMessage(w, r, "Wrong OpenID role, the logged in user is not an SFTPGo admin") + setFlashMessage(w, r, newFlashMessage( + "Wrong OpenID role, the logged in user is not an SFTPGo admin", + util.I18nOIDCTokenInvalidRoleAdmin)) doRedirect() doLogout(rawIDToken) return @@ -680,7 +688,10 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request) case tokenAudienceWebClient: if token.isAdmin() { logger.Debug(logSender, "", "wrong oidc token role, the mapped user is an SFTPGo admin") - setFlashMessage(w, r, "Wrong OpenID role, the logged in user is an SFTPGo admin") + setFlashMessage(w, r, newFlashMessage( + "Wrong OpenID role, the logged in user is an SFTPGo admin", + util.I18nOIDCTokenInvalidRoleUser, + )) doRedirect() doLogout(rawIDToken) return @@ -689,7 +700,7 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request) err = token.getUser(r) if err != nil { logger.Debug(logSender, "", "unable to get the sftpgo user associated with oidc token: %v", err) - setFlashMessage(w, r, "Unable to get the user associated with the OpenID token") + setFlashMessage(w, r, newFlashMessage("Unable to get the user associated with the OpenID token", util.I18nOIDCErrGetUser)) doRedirect() doLogout(rawIDToken) return diff --git a/internal/httpd/server.go b/internal/httpd/server.go index 525602fd..e80b65c2 100644 --- a/internal/httpd/server.go +++ b/internal/httpd/server.go @@ -160,12 +160,12 @@ func (s *httpdServer) refreshCookie(next http.Handler) http.Handler { }) } -func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, r *http.Request, error, ip string) { - data := loginPage{ +func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) { + data := clientLoginPage{ commonBasePage: getCommonBasePage(r), Title: util.I18nLoginTitle, CurrentURL: webClientLoginPath, - Error: error, + Error: err, CSRFToken: createCSRFToken(ip), Branding: s.binding.Branding.WebClient, FormDisabled: s.binding.isWebClientLoginFormDisabled(), @@ -198,7 +198,7 @@ func (s *httpdServer) handleWebClientLogout(w http.ResponseWriter, r *http.Reque func (s *httpdServer) handleWebClientChangePwdPost(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) if err := r.ParseForm(); err != nil { - s.renderClientChangePasswordPage(w, r, util.I18nErrorInvalidForm) + s.renderClientChangePasswordPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm)) return } if err := verifyCSRFToken(r.Form.Get(csrfFormToken), util.GetIPFromRemoteAddress(r.RemoteAddr)); err != nil { @@ -208,7 +208,7 @@ func (s *httpdServer) handleWebClientChangePwdPost(w http.ResponseWriter, r *htt err := doChangeUserPassword(r, strings.TrimSpace(r.Form.Get("current_password")), strings.TrimSpace(r.Form.Get("new_password1")), strings.TrimSpace(r.Form.Get("new_password2"))) if err != nil { - s.renderClientChangePasswordPage(w, r, getI18NErrorString(err, util.I18nErrorChangePwdGeneric)) + s.renderClientChangePasswordPage(w, r, util.NewI18nError(err, util.I18nErrorChangePwdGeneric)) return } s.handleWebClientLogout(w, r) @@ -220,7 +220,8 @@ func (s *httpdServer) handleClientWebLogin(w http.ResponseWriter, r *http.Reques http.Redirect(w, r, webAdminSetupPath, http.StatusFound) return } - s.renderClientLoginPage(w, r, getFlashMessage(w, r), util.GetIPFromRemoteAddress(r.RemoteAddr)) + msg := getFlashMessage(w, r) + s.renderClientLoginPage(w, r, msg.getI18nError(), util.GetIPFromRemoteAddress(r.RemoteAddr)) } func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Request) { @@ -228,7 +229,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) if err := r.ParseForm(); err != nil { - s.renderClientLoginPage(w, r, util.I18nErrorInvalidForm, ipAddr) + s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr) return } protocol := common.ProtocolHTTP @@ -237,33 +238,35 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re if username == "" || password == "" { updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials) - s.renderClientLoginPage(w, r, util.I18nErrorInvalidCredentials, ipAddr) + s.renderClientLoginPage(w, r, + util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr) return } if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil { updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, dataprovider.LoginMethodPassword, ipAddr, err) - s.renderClientLoginPage(w, r, util.I18nErrorInvalidCSRF, ipAddr) + s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr) return } if err := common.Config.ExecutePostConnectHook(ipAddr, protocol); err != nil { updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, dataprovider.LoginMethodPassword, ipAddr, err) - s.renderClientLoginPage(w, r, util.I18nError403Message, ipAddr) + s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nError403Message), ipAddr) return } user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, protocol) if err != nil { updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err) - s.renderClientLoginPage(w, r, util.I18nErrorInvalidCredentials, ipAddr) + s.renderClientLoginPage(w, r, + util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr) return } connectionID := fmt.Sprintf("%v_%v", protocol, xid.New().String()) if err := checkHTTPClientUser(&user, r, connectionID, true); err != nil { updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err) - s.renderClientLoginPage(w, r, getI18NErrorString(err, util.I18nError403Message), ipAddr) + s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nError403Message), ipAddr) return } @@ -272,7 +275,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, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure) - s.renderClientLoginPage(w, r, getI18NErrorString(err, util.I18nErrorFsGeneric), ipAddr) + s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nErrorFsGeneric), ipAddr) return } s.loginUser(w, r, &user, connectionID, ipAddr, false, s.renderClientLoginPage) @@ -284,7 +287,7 @@ func (s *httpdServer) handleWebClientPasswordResetPost(w http.ResponseWriter, r ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) err := r.ParseForm() if err != nil { - s.renderClientResetPwdPage(w, r, util.I18nErrorInvalidForm, ipAddr) + s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr) return } if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil { @@ -294,18 +297,20 @@ func (s *httpdServer) handleWebClientPasswordResetPost(w http.ResponseWriter, r newPassword := strings.TrimSpace(r.Form.Get("password")) confirmPassword := strings.TrimSpace(r.Form.Get("confirm_password")) if newPassword != confirmPassword { - s.renderClientResetPwdPage(w, r, util.I18nErrorChangePwdNoMatch, ipAddr) + s.renderClientResetPwdPage(w, r, util.NewI18nError( + errors.New("the two password fields do not match"), + util.I18nErrorChangePwdNoMatch), ipAddr) return } _, user, err := handleResetPassword(r, strings.TrimSpace(r.Form.Get("code")), newPassword, false) if err != nil { - s.renderClientResetPwdPage(w, r, getI18NErrorString(err, util.I18nErrorChangePwdGeneric), ipAddr) + s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorChangePwdGeneric), ipAddr) return } connectionID := fmt.Sprintf("%v_%v", getProtocolFromRequest(r), xid.New().String()) if err := checkHTTPClientUser(user, r, connectionID, true); err != nil { - s.renderClientResetPwdPage(w, r, getI18NErrorString(err, util.I18nErrorDirList403), ipAddr) + s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorDirList403), ipAddr) return } @@ -313,7 +318,7 @@ func (s *httpdServer) handleWebClientPasswordResetPost(w http.ResponseWriter, r err = user.CheckFsRoot(connectionID) if err != nil { logger.Warn(logSender, connectionID, "unable to check fs root: %v", err) - s.renderClientResetPwdPage(w, r, util.I18nErrorLoginAfterReset, ipAddr) + s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorLoginAfterReset), ipAddr) return } s.loginUser(w, r, user, connectionID, ipAddr, false, s.renderClientResetPwdPage) @@ -328,17 +333,18 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) if err := r.ParseForm(); err != nil { - s.renderClientTwoFactorRecoveryPage(w, r, util.I18nErrorInvalidForm, ipAddr) + s.renderClientTwoFactorRecoveryPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr) return } username := claims.Username recoveryCode := strings.TrimSpace(r.Form.Get("recovery_code")) if username == "" || recoveryCode == "" { - s.renderClientTwoFactorRecoveryPage(w, r, util.I18nErrorInvalidCredentials, ipAddr) + s.renderClientTwoFactorRecoveryPage(w, r, + util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr) return } if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil { - s.renderClientTwoFactorRecoveryPage(w, r, util.I18nErrorInvalidCSRF, ipAddr) + s.renderClientTwoFactorRecoveryPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr) return } user, userMerged, err := dataprovider.GetUserVariants(username, "") @@ -346,11 +352,13 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter if errors.Is(err, util.ErrNotFound) { handleDefenderEventLoginFailed(ipAddr, err) //nolint:errcheck } - s.renderClientTwoFactorRecoveryPage(w, r, util.I18nErrorInvalidCredentials, ipAddr) + s.renderClientTwoFactorRecoveryPage(w, r, + util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr) return } if !userMerged.Filters.TOTPConfig.Enabled || !util.Contains(userMerged.Filters.TOTPConfig.Protocols, common.ProtocolHTTP) { - s.renderClientTwoFactorPage(w, r, "Two factory authentication is not enabled", ipAddr) + s.renderClientTwoFactorPage(w, r, util.NewI18nError( + util.NewValidationError("two factory authentication is not enabled"), util.I18n2FADisabled), ipAddr) return } for idx, code := range user.Filters.RecoveryCodes { @@ -360,7 +368,8 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter } if code.Secret.GetPayload() == recoveryCode { if code.Used { - s.renderClientTwoFactorRecoveryPage(w, r, util.I18nErrorInvalidCredentials, ipAddr) + s.renderClientTwoFactorRecoveryPage(w, r, + util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr) return } user.Filters.RecoveryCodes[idx].Used = true @@ -377,7 +386,8 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter } } handleDefenderEventLoginFailed(ipAddr, dataprovider.ErrInvalidCredentials) //nolint:errcheck - s.renderClientTwoFactorRecoveryPage(w, r, util.I18nErrorInvalidCredentials, ipAddr) + s.renderClientTwoFactorRecoveryPage(w, r, + util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr) } func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *http.Request) { @@ -389,7 +399,7 @@ func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *htt } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) if err := r.ParseForm(); err != nil { - s.renderClientTwoFactorPage(w, r, util.I18nErrorInvalidForm, ipAddr) + s.renderClientTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr) return } username := claims.Username @@ -397,25 +407,26 @@ func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *htt if username == "" || passcode == "" { updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials) - s.renderClientTwoFactorPage(w, r, util.I18nErrorInvalidCredentials, ipAddr) + s.renderClientTwoFactorPage(w, r, + util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr) return } if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil { updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, dataprovider.LoginMethodPassword, ipAddr, err) - s.renderClientTwoFactorPage(w, r, util.I18nErrorInvalidCSRF, ipAddr) + s.renderClientTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr) return } user, err := dataprovider.GetUserWithGroupSettings(username, "") if err != nil { updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, dataprovider.LoginMethodPassword, ipAddr, err) - s.renderClientTwoFactorPage(w, r, util.I18nErrorInvalidCredentials, ipAddr) + s.renderClientTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCredentials), ipAddr) return } if !user.Filters.TOTPConfig.Enabled || !util.Contains(user.Filters.TOTPConfig.Protocols, common.ProtocolHTTP) { updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure) - s.renderClientTwoFactorPage(w, r, util.I18n2FADisabled, ipAddr) + s.renderClientTwoFactorPage(w, r, util.NewI18nError(common.ErrInternalFailure, util.I18n2FADisabled), ipAddr) return } err = user.Filters.TOTPConfig.Secret.Decrypt() @@ -428,7 +439,8 @@ func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *htt user.Filters.TOTPConfig.Secret.GetPayload()) if !match || err != nil { updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, dataprovider.ErrInvalidCredentials) - s.renderClientTwoFactorPage(w, r, util.I18nErrorInvalidCredentials, ipAddr) + s.renderClientTwoFactorPage(w, r, + util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr) return } connectionID := fmt.Sprintf("%s_%s", getProtocolFromRequest(r), xid.New().String()) @@ -601,7 +613,7 @@ func (s *httpdServer) handleWebAdminLogin(w http.ResponseWriter, r *http.Request http.Redirect(w, r, webAdminSetupPath, http.StatusFound) return } - s.renderAdminLoginPage(w, r, getFlashMessage(w, r), util.GetIPFromRemoteAddress(r.RemoteAddr)) + s.renderAdminLoginPage(w, r, getFlashMessage(w, r).ErrorString, util.GetIPFromRemoteAddress(r.RemoteAddr)) } func (s *httpdServer) handleWebAdminLogout(w http.ResponseWriter, r *http.Request) { @@ -649,7 +661,8 @@ func (s *httpdServer) handleWebAdminPasswordResetPost(w http.ResponseWriter, r * admin, _, err := handleResetPassword(r, strings.TrimSpace(r.Form.Get("code")), strings.TrimSpace(r.Form.Get("password")), true) if err != nil { - if e, ok := err.(*util.ValidationError); ok { + var e *util.ValidationError + if errors.As(err, &e) { s.renderResetPwdPage(w, r, e.GetErrorString(), ipAddr) return } @@ -712,7 +725,7 @@ func (s *httpdServer) handleWebAdminSetupPost(w http.ResponseWriter, r *http.Req func (s *httpdServer) loginUser( w http.ResponseWriter, r *http.Request, user *dataprovider.User, connectionID, ipAddr string, - isSecondFactorAuth bool, errorFunc func(w http.ResponseWriter, r *http.Request, error, ip string), + isSecondFactorAuth bool, errorFunc func(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string), ) { c := jwtTokenClaims{ Username: user.Username, @@ -734,7 +747,7 @@ func (s *httpdServer) loginUser( if err != nil { logger.Warn(logSender, connectionID, "unable to set user login cookie %v", err) updateLoginMetrics(user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure) - errorFunc(w, r, util.I18nError500Message, ipAddr) + errorFunc(w, r, util.NewI18nError(err, util.I18nError500Message), ipAddr) return } if isSecondFactorAuth { diff --git a/internal/httpd/web.go b/internal/httpd/web.go index 050f1d30..4c20aa84 100644 --- a/internal/httpd/web.go +++ b/internal/httpd/web.go @@ -144,7 +144,7 @@ func i18nFsMsg(status int) string { func getI18NErrorString(err error, fallback string) string { var errI18n *util.I18nError if errors.As(err, &errI18n) { - return errI18n.I18nMessage + return errI18n.Message } return fallback } diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index 7e984724..d81fafee 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -2652,7 +2652,8 @@ func (s *httpdServer) handleWebAdminForgotPwdPost(w http.ResponseWriter, r *http } err = handleForgotPassword(r, r.Form.Get("username"), true) if err != nil { - if e, ok := err.(*util.ValidationError); ok { + var e *util.ValidationError + if errors.As(err, &e) { s.renderForgotPwdPage(w, r, e.GetErrorString(), ipAddr) return } diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go index 601a83bb..ac9925bc 100644 --- a/internal/httpd/webclient.go +++ b/internal/httpd/webclient.go @@ -141,7 +141,7 @@ type filesPage struct { CanDownload bool CanShare bool ShareUploadBaseURL string - Error string + Error *util.I18nError Paths []dirMapping QuotaUsage *userQuotaUsage } @@ -149,7 +149,7 @@ type filesPage struct { type shareLoginPage struct { commonBasePage CurrentURL string - Error string + Error *util.I18nError CSRFToken string Title string Branding UIBranding @@ -168,7 +168,7 @@ type shareUploadPage struct { type clientMessagePage struct { baseClientPage - Error string + Error *util.I18nError Success string } @@ -179,23 +179,24 @@ type clientProfilePage struct { AllowAPIKeyAuth bool Email string Description string - Error string + Error *util.I18nError } type changeClientPasswordPage struct { baseClientPage - Error string + Error *util.I18nError } type clientMFAPage struct { baseClientPage - TOTPConfigs []string - TOTPConfig dataprovider.UserTOTPConfig - GenerateTOTPURL string - ValidateTOTPURL string - SaveTOTPURL string - RecCodesURL string - Protocols []string + TOTPConfigs []string + TOTPConfig dataprovider.UserTOTPConfig + GenerateTOTPURL string + ValidateTOTPURL string + SaveTOTPURL string + RecCodesURL string + Protocols []string + RequiredProtocols []string } type clientSharesPage struct { @@ -207,10 +208,55 @@ type clientSharesPage struct { type clientSharePage struct { baseClientPage Share *dataprovider.Share - Error string + Error *util.I18nError IsAdd bool } +// TODO: merge with loginPage once the WebAdmin supports localization +type clientLoginPage struct { + commonBasePage + CurrentURL string + Error *util.I18nError + CSRFToken string + AltLoginURL string + AltLoginName string + ForgotPwdURL string + OpenIDLoginURL string + Title string + Branding UIBranding + FormDisabled bool +} + +type clientResetPwdPage struct { + commonBasePage + CurrentURL string + Error *util.I18nError + CSRFToken string + LoginURL string + Title string + Branding UIBranding +} + +type clientTwoFactorPage struct { + commonBasePage + CurrentURL string + Error *util.I18nError + CSRFToken string + RecoveryURL string + Title string + Branding UIBranding +} + +type clientForgotPwdPage struct { + commonBasePage + CurrentURL string + Error *util.I18nError + CSRFToken string + LoginURL string + Title string + Branding UIBranding +} + type userQuotaUsage struct { QuotaSize int64 QuotaFiles int @@ -553,11 +599,11 @@ func (s *httpdServer) getBaseClientPageData(title, currentURL string, r *http.Re return data } -func (s *httpdServer) renderClientForgotPwdPage(w http.ResponseWriter, r *http.Request, error, ip string) { - data := forgotPwdPage{ +func (s *httpdServer) renderClientForgotPwdPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) { + data := clientForgotPwdPage{ commonBasePage: getCommonBasePage(r), CurrentURL: webClientForgotPwdPath, - Error: error, + Error: err, CSRFToken: createCSRFToken(ip), LoginURL: webClientLoginPath, Title: util.I18nForgotPwdTitle, @@ -566,11 +612,11 @@ func (s *httpdServer) renderClientForgotPwdPage(w http.ResponseWriter, r *http.R renderClientTemplate(w, templateForgotPassword, data) } -func (s *httpdServer) renderClientResetPwdPage(w http.ResponseWriter, r *http.Request, error, ip string) { - data := resetPwdPage{ +func (s *httpdServer) renderClientResetPwdPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) { + data := clientResetPwdPage{ commonBasePage: getCommonBasePage(r), CurrentURL: webClientResetPwdPath, - Error: error, + Error: err, CSRFToken: createCSRFToken(ip), LoginURL: webClientLoginPath, Title: util.I18nResetPwdTitle, @@ -579,12 +625,12 @@ func (s *httpdServer) renderClientResetPwdPage(w http.ResponseWriter, r *http.Re renderClientTemplate(w, templateResetPassword, data) } -func (s *httpdServer) renderShareLoginPage(w http.ResponseWriter, r *http.Request, error, ip string) { +func (s *httpdServer) renderShareLoginPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) { data := shareLoginPage{ commonBasePage: getCommonBasePage(r), Title: util.I18nShareLoginTitle, CurrentURL: r.RequestURI, - Error: error, + Error: err, CSRFToken: createCSRFToken(ip), Branding: s.binding.Branding.WebClient, } @@ -599,13 +645,13 @@ func renderClientTemplate(w http.ResponseWriter, tmplName string, data any) { } func (s *httpdServer) renderClientMessagePage(w http.ResponseWriter, r *http.Request, title string, statusCode int, err error, message string) { - var errString string + var i18nErr *util.I18nError if err != nil { - errString = getI18NErrorString(err, util.I18nError500Message) + i18nErr = util.NewI18nError(err, util.I18nError500Message) } data := clientMessagePage{ baseClientPage: s.getBaseClientPageData(title, "", r), - Error: errString, + Error: i18nErr, Success: message, } w.WriteHeader(statusCode) @@ -628,16 +674,16 @@ func (s *httpdServer) renderClientForbiddenPage(w http.ResponseWriter, r *http.R } func (s *httpdServer) renderClientNotFoundPage(w http.ResponseWriter, r *http.Request, err error) { - s.renderClientMessagePage(w, r, util.I18nError400Title, http.StatusNotFound, - util.NewI18nError(err, util.I18nError400Message), "") + s.renderClientMessagePage(w, r, util.I18nError404Title, http.StatusNotFound, + util.NewI18nError(err, util.I18nError404Message), "") } -func (s *httpdServer) renderClientTwoFactorPage(w http.ResponseWriter, r *http.Request, errorString, ip string) { - data := twoFactorPage{ +func (s *httpdServer) renderClientTwoFactorPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) { + data := clientTwoFactorPage{ commonBasePage: getCommonBasePage(r), Title: pageTwoFactorTitle, CurrentURL: webClientTwoFactorPath, - Error: errorString, + Error: err, CSRFToken: createCSRFToken(ip), RecoveryURL: webClientTwoFactorRecoveryPath, Branding: s.binding.Branding.WebClient, @@ -648,12 +694,12 @@ func (s *httpdServer) renderClientTwoFactorPage(w http.ResponseWriter, r *http.R renderClientTemplate(w, templateClientTwoFactor, data) } -func (s *httpdServer) renderClientTwoFactorRecoveryPage(w http.ResponseWriter, r *http.Request, errorString, ip string) { - data := twoFactorPage{ +func (s *httpdServer) renderClientTwoFactorRecoveryPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) { + data := clientTwoFactorPage{ commonBasePage: getCommonBasePage(r), Title: pageTwoFactorRecoveryTitle, CurrentURL: webClientTwoFactorRecoveryPath, - Error: errorString, + Error: err, CSRFToken: createCSRFToken(ip), Branding: s.binding.Branding.WebClient, } @@ -676,6 +722,7 @@ func (s *httpdServer) renderClientMFAPage(w http.ResponseWriter, r *http.Request return } data.TOTPConfig = user.Filters.TOTPConfig + data.RequiredProtocols = user.Filters.TwoFactorAuthProtocols renderClientTemplate(w, templateClientMFA, data) } @@ -698,7 +745,7 @@ func (s *httpdServer) renderEditFilePage(w http.ResponseWriter, r *http.Request, } func (s *httpdServer) renderAddUpdateSharePage(w http.ResponseWriter, r *http.Request, share *dataprovider.Share, - errorString string, isAdd bool) { + err *util.I18nError, isAdd bool) { currentURL := webClientSharePath title := util.I18nShareAddTitle if !isAdd { @@ -708,7 +755,7 @@ func (s *httpdServer) renderAddUpdateSharePage(w http.ResponseWriter, r *http.Re data := clientSharePage{ baseClientPage: s.getBaseClientPageData(title, currentURL, r), Share: share, - Error: errorString, + Error: err, IsAdd: isAdd, } @@ -736,8 +783,8 @@ func getDirMapping(dirName, baseWebPath string) []dirMapping { return paths } -func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, - share dataprovider.Share, +func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Request, dirName string, + err *util.I18nError, share dataprovider.Share, ) { currentURL := path.Join(webClientPubSharesPath, share.ShareID, "browse") baseData := s.getBaseClientPageData(util.I18nSharedFilesTitle, currentURL, r) @@ -746,7 +793,7 @@ func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Reque data := filesPage{ baseClientPage: baseData, - Error: error, + Error: err, CurrentDir: url.QueryEscape(dirName), DownloadURL: path.Join(baseSharePath, "partial"), // dirName must be escaped because the router expects the full path as single argument @@ -785,10 +832,11 @@ func (s *httpdServer) renderUploadToSharePage(w http.ResponseWriter, r *http.Req renderClientTemplate(w, templateUploadToShare, data) } -func (s *httpdServer) renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, user *dataprovider.User) { +func (s *httpdServer) renderFilesPage(w http.ResponseWriter, r *http.Request, dirName string, + err *util.I18nError, user *dataprovider.User) { data := filesPage{ baseClientPage: s.getBaseClientPageData(util.I18nFilesTitle, webClientFilesPath, r), - Error: error, + Error: err, CurrentDir: url.QueryEscape(dirName), DownloadURL: webClientDownloadZipPath, ViewPDFURL: webClientViewPDFPath, @@ -808,14 +856,14 @@ func (s *httpdServer) renderFilesPage(w http.ResponseWriter, r *http.Request, di renderClientTemplate(w, templateClientFiles, data) } -func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Request, error string) { +func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Request, err *util.I18nError) { data := clientProfilePage{ baseClientPage: s.getBaseClientPageData(util.I18nProfileTitle, webClientProfilePath, r), - Error: error, + Error: err, } - user, userMerged, err := dataprovider.GetUserVariants(data.LoggedUser.Username, "") - if err != nil { - s.renderClientInternalServerErrorPage(w, r, err) + user, userMerged, errUser := dataprovider.GetUserVariants(data.LoggedUser.Username, "") + if errUser != nil { + s.renderClientInternalServerErrorPage(w, r, errUser) return } data.PublicKeys = user.PublicKeys @@ -826,10 +874,10 @@ func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Req renderClientTemplate(w, templateClientProfile, data) } -func (s *httpdServer) renderClientChangePasswordPage(w http.ResponseWriter, r *http.Request, error string) { +func (s *httpdServer) renderClientChangePasswordPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) { data := changeClientPasswordPage{ baseClientPage: s.getBaseClientPageData(util.I18nChangePwdTitle, webChangeClientPwdPath, r), - Error: error, + Error: err, } renderClientTemplate(w, templateClientChangePwd, data) @@ -1023,7 +1071,8 @@ func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request } if err = common.Connections.Add(connection); err != nil { - s.renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)), util.I18nError429Message, share) + s.renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)), + util.NewI18nError(err, util.I18nError429Message), share) return } defer common.Connections.Remove(connection.GetID()) @@ -1035,18 +1084,20 @@ func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request info, err = connection.Stat(name, 1) } if err != nil { - s.renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)), i18nFsMsg(getRespStatus(err)), share) + s.renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)), + util.NewI18nError(err, i18nFsMsg(getRespStatus(err))), share) return } if info.IsDir() { - s.renderSharedFilesPage(w, r, share.GetRelativePath(name), "", share) + s.renderSharedFilesPage(w, r, share.GetRelativePath(name), nil, share) return } dataprovider.UpdateShareLastUse(&share, 1) //nolint:errcheck if status, err := downloadFile(w, r, connection, name, info, false, &share); err != nil { dataprovider.UpdateShareLastUse(&share, -1) //nolint:errcheck if status > 0 { - s.renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)), i18nFsMsg(getRespStatus(err)), share) + s.renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)), + util.NewI18nError(err, i18nFsMsg(getRespStatus(err))), share) } } } @@ -1228,11 +1279,11 @@ func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Reques info, err = connection.Stat(name, 0) } if err != nil { - s.renderFilesPage(w, r, path.Dir(name), i18nFsMsg(getRespStatus(err)), &user) + s.renderFilesPage(w, r, path.Dir(name), util.NewI18nError(err, i18nFsMsg(getRespStatus(err))), &user) return } if info.IsDir() { - s.renderFilesPage(w, r, name, "", &user) + s.renderFilesPage(w, r, name, nil, &user) return } if status, err := downloadFile(w, r, connection, name, info, false, nil); err != nil && status != 0 { @@ -1242,7 +1293,7 @@ func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Reques util.NewI18nError(err, util.I18nError416Message), "") return } - s.renderFilesPage(w, r, path.Dir(name), i18nFsMsg(status), &user) + s.renderFilesPage(w, r, path.Dir(name), util.NewI18nError(err, i18nFsMsg(status)), &user) } } } @@ -1365,7 +1416,7 @@ func (s *httpdServer) handleClientAddShareGet(w http.ResponseWriter, r *http.Req } } - s.renderAddUpdateSharePage(w, r, share, "", true) + s.renderAddUpdateSharePage(w, r, share, nil, true) } func (s *httpdServer) handleClientUpdateShareGet(w http.ResponseWriter, r *http.Request) { @@ -1379,7 +1430,7 @@ func (s *httpdServer) handleClientUpdateShareGet(w http.ResponseWriter, r *http. share, err := dataprovider.ShareExists(shareID, claims.Username) if err == nil { share.HideConfidentialData() - s.renderAddUpdateSharePage(w, r, &share, "", false) + s.renderAddUpdateSharePage(w, r, &share, nil, false) } else if errors.Is(err, util.ErrNotFound) { s.renderClientNotFoundPage(w, r, err) } else { @@ -1396,7 +1447,7 @@ func (s *httpdServer) handleClientAddSharePost(w http.ResponseWriter, r *http.Re } share, err := getShareFromPostFields(r) if err != nil { - s.renderAddUpdateSharePage(w, r, share, getI18NErrorString(err, util.I18nError500Message), true) + s.renderAddUpdateSharePage(w, r, share, util.NewI18nError(err, util.I18nError500Message), true) return } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) @@ -1410,24 +1461,39 @@ func (s *httpdServer) handleClientAddSharePost(w http.ResponseWriter, r *http.Re share.Username = claims.Username if share.Password == "" { if util.Contains(claims.Permissions, sdk.WebClientShareNoPasswordDisabled) { - s.renderAddUpdateSharePage(w, r, share, util.I18nErrorShareNoPwd, true) + s.renderAddUpdateSharePage(w, r, share, + util.NewI18nError(util.NewValidationError("You are not allowed to share files/folders without password"), util.I18nErrorShareNoPwd), + true) return } } user, err := dataprovider.GetUserWithGroupSettings(claims.Username, "") if err != nil { - s.renderAddUpdateSharePage(w, r, share, util.I18nErrorGetUser, true) + s.renderAddUpdateSharePage(w, r, share, util.NewI18nError(err, util.I18nErrorGetUser), true) return } if err := user.CheckMaxShareExpiration(util.GetTimeFromMsecSinceEpoch(share.ExpiresAt)); err != nil { - s.renderAddUpdateSharePage(w, r, share, util.I18nErrorShareExpirationOutOfRange, true) + s.renderAddUpdateSharePage(w, r, share, util.NewI18nError( + err, + util.I18nErrorShareExpirationOutOfRange, + util.I18nErrorArgs( + map[string]any{ + "val": time.Now().Add(24 * time.Hour * time.Duration(user.Filters.MaxSharesExpiration+1)).UnixMilli(), + "formatParams": map[string]string{ + "year": "numeric", + "month": "numeric", + "day": "numeric", + }, + }, + ), + ), true) return } err = dataprovider.AddShare(share, claims.Username, ipAddr, claims.Role) if err == nil { http.Redirect(w, r, webClientSharesPath, http.StatusSeeOther) } else { - s.renderAddUpdateSharePage(w, r, share, getI18NErrorString(err, util.I18nErrorShareGeneric), true) + s.renderAddUpdateSharePage(w, r, share, util.NewI18nError(err, util.I18nErrorShareGeneric), true) } } @@ -1449,7 +1515,7 @@ func (s *httpdServer) handleClientUpdateSharePost(w http.ResponseWriter, r *http } updatedShare, err := getShareFromPostFields(r) if err != nil { - s.renderAddUpdateSharePage(w, r, updatedShare, getI18NErrorString(err, util.I18nError500Message), false) + s.renderAddUpdateSharePage(w, r, updatedShare, util.NewI18nError(err, util.I18nError500Message), false) return } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) @@ -1464,24 +1530,39 @@ func (s *httpdServer) handleClientUpdateSharePost(w http.ResponseWriter, r *http } if updatedShare.Password == "" { if util.Contains(claims.Permissions, sdk.WebClientShareNoPasswordDisabled) { - s.renderAddUpdateSharePage(w, r, updatedShare, util.I18nErrorShareNoPwd, false) + s.renderAddUpdateSharePage(w, r, updatedShare, + util.NewI18nError(util.NewValidationError("You are not allowed to share files/folders without password"), util.I18nErrorShareNoPwd), + false) return } } user, err := dataprovider.GetUserWithGroupSettings(claims.Username, "") if err != nil { - s.renderAddUpdateSharePage(w, r, updatedShare, util.I18nErrorGetUser, false) + s.renderAddUpdateSharePage(w, r, updatedShare, util.NewI18nError(err, util.I18nErrorGetUser), false) return } if err := user.CheckMaxShareExpiration(util.GetTimeFromMsecSinceEpoch(updatedShare.ExpiresAt)); err != nil { - s.renderAddUpdateSharePage(w, r, updatedShare, util.I18nErrorShareExpirationOutOfRange, false) + s.renderAddUpdateSharePage(w, r, updatedShare, util.NewI18nError( + err, + util.I18nErrorShareExpirationOutOfRange, + util.I18nErrorArgs( + map[string]any{ + "val": time.Now().Add(24 * time.Hour * time.Duration(user.Filters.MaxSharesExpiration+1)).UnixMilli(), + "formatParams": map[string]string{ + "year": "numeric", + "month": "numeric", + "day": "numeric", + }, + }, + ), + ), false) return } err = dataprovider.UpdateShare(updatedShare, claims.Username, ipAddr, claims.Role) if err == nil { http.Redirect(w, r, webClientSharesPath, http.StatusSeeOther) } else { - s.renderAddUpdateSharePage(w, r, updatedShare, getI18NErrorString(err, util.I18nErrorShareGeneric), false) + s.renderAddUpdateSharePage(w, r, updatedShare, util.NewI18nError(err, util.I18nErrorShareGeneric), false) } } @@ -1522,19 +1603,19 @@ func (s *httpdServer) handleClientGetShares(w http.ResponseWriter, r *http.Reque func (s *httpdServer) handleClientGetProfile(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - s.renderClientProfilePage(w, r, "") + s.renderClientProfilePage(w, r, nil) } func (s *httpdServer) handleWebClientChangePwd(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - s.renderClientChangePasswordPage(w, r, "") + s.renderClientChangePasswordPage(w, r, nil) } func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) err := r.ParseForm() if err != nil { - s.renderClientProfilePage(w, r, util.I18nErrorInvalidForm) + s.renderClientProfilePage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm)) return } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) @@ -1549,7 +1630,7 @@ func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http. } user, userMerged, err := dataprovider.GetUserVariants(claims.Username, "") if err != nil { - s.renderClientProfilePage(w, r, util.I18nErrorGetUser) + s.renderClientProfilePage(w, r, util.NewI18nError(err, util.I18nErrorGetUser)) return } if !userMerged.CanManagePublicKeys() && !userMerged.CanChangeAPIKeyAuth() && !userMerged.CanChangeInfo() { @@ -1576,7 +1657,7 @@ func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http. } err = dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, ipAddr, user.Role) if err != nil { - s.renderClientProfilePage(w, r, getI18NErrorString(err, util.I18nError500Message)) + s.renderClientProfilePage(w, r, util.NewI18nError(err, util.I18nError500Message)) return } s.renderClientMessagePage(w, r, util.I18nProfileTitle, http.StatusOK, nil, util.I18nProfileUpdated) @@ -1589,12 +1670,12 @@ func (s *httpdServer) handleWebClientMFA(w http.ResponseWriter, r *http.Request) func (s *httpdServer) handleWebClientTwoFactor(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - s.renderClientTwoFactorPage(w, r, "", util.GetIPFromRemoteAddress(r.RemoteAddr)) + s.renderClientTwoFactorPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr)) } func (s *httpdServer) handleWebClientTwoFactorRecovery(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - s.renderClientTwoFactorRecoveryPage(w, r, "", util.GetIPFromRemoteAddress(r.RemoteAddr)) + s.renderClientTwoFactorRecoveryPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr)) } func getShareFromPostFields(r *http.Request) (*dataprovider.Share, error) { @@ -1646,7 +1727,7 @@ func (s *httpdServer) handleWebClientForgotPwd(w http.ResponseWriter, r *http.Re s.renderClientNotFoundPage(w, r, errors.New("this page does not exist")) return } - s.renderClientForgotPwdPage(w, r, "", util.GetIPFromRemoteAddress(r.RemoteAddr)) + s.renderClientForgotPwdPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr)) } func (s *httpdServer) handleWebClientForgotPwdPost(w http.ResponseWriter, r *http.Request) { @@ -1655,7 +1736,7 @@ func (s *httpdServer) handleWebClientForgotPwdPost(w http.ResponseWriter, r *htt ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) err := r.ParseForm() if err != nil { - s.renderClientForgotPwdPage(w, r, util.I18nErrorInvalidForm, ipAddr) + s.renderClientForgotPwdPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr) return } if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil { @@ -1665,7 +1746,7 @@ func (s *httpdServer) handleWebClientForgotPwdPost(w http.ResponseWriter, r *htt username := strings.TrimSpace(r.Form.Get("username")) err = handleForgotPassword(r, username, false) if err != nil { - s.renderClientForgotPwdPage(w, r, getI18NErrorString(err, util.I18nErrorPwdResetGeneric), ipAddr) + s.renderClientForgotPwdPage(w, r, util.NewI18nError(err, util.I18nErrorPwdResetGeneric), ipAddr) return } http.Redirect(w, r, webClientResetPwdPath, http.StatusFound) @@ -1677,7 +1758,7 @@ func (s *httpdServer) handleWebClientPasswordReset(w http.ResponseWriter, r *htt s.renderClientNotFoundPage(w, r, errors.New("this page does not exist")) return } - s.renderClientResetPwdPage(w, r, "", util.GetIPFromRemoteAddress(r.RemoteAddr)) + s.renderClientResetPwdPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr)) } func (s *httpdServer) handleClientViewPDF(w http.ResponseWriter, r *http.Request) { @@ -1780,29 +1861,30 @@ func (s *httpdServer) ensurePDF(w http.ResponseWriter, r *http.Request, name str func (s *httpdServer) handleClientShareLoginGet(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize) - s.renderShareLoginPage(w, r, "", util.GetIPFromRemoteAddress(r.RemoteAddr)) + s.renderShareLoginPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr)) } func (s *httpdServer) handleClientShareLoginPost(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize) ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) if err := r.ParseForm(); err != nil { - s.renderShareLoginPage(w, r, util.I18nErrorInvalidForm, ipAddr) + s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr) return } if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil { - s.renderShareLoginPage(w, r, util.I18nErrorInvalidCSRF, ipAddr) + s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr) return } shareID := getURLParam(r, "id") share, err := dataprovider.ShareExists(shareID, "") if err != nil { - s.renderShareLoginPage(w, r, util.I18nErrorInvalidCredentials, ipAddr) + s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCredentials), ipAddr) return } match, err := share.CheckCredentials(strings.TrimSpace(r.Form.Get("share_password"))) if !match || err != nil { - s.renderShareLoginPage(w, r, util.I18nErrorInvalidCredentials, ipAddr) + s.renderShareLoginPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), + ipAddr) return } c := jwtTokenClaims{ @@ -1810,7 +1892,7 @@ func (s *httpdServer) handleClientShareLoginPost(w http.ResponseWriter, r *http. } err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebShare, ipAddr) if err != nil { - s.renderShareLoginPage(w, r, util.I18nError500Message, ipAddr) + s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nError500Message), ipAddr) return } next := path.Clean(r.URL.Query().Get("next")) diff --git a/internal/util/i18n.go b/internal/util/i18n.go index 77ca7481..b56bc466 100644 --- a/internal/util/i18n.go +++ b/internal/util/i18n.go @@ -15,6 +15,7 @@ package util import ( + "encoding/json" "errors" ) @@ -40,6 +41,7 @@ const ( I18nInvalidAuthReqTitle = "title.invalid_auth_request" I18nError403Title = "title.error403" I18nError400Title = "title.error400" + I18nError404Title = "title.error404" I18nError416Title = "title.error416" I18nError429Title = "title.error429" I18nError500Title = "title.error500" @@ -136,24 +138,48 @@ const ( I18nProfileUpdated = "general.profile_updated" I18nShareLoginOK = "general.share_ok" I18n2FADisabled = "2fa.disabled" + I18nOIDCTokenExpired = "oidc.token_expired" + I18nOIDCTokenInvalidAdmin = "oidc.token_invalid_webadmin" + I18nOIDCTokenInvalidUser = "oidc.token_invalid_webclient" + I18nOIDCErrTokenExchange = "oidc.token_exchange_err" + I18nOIDCTokenInvalid = "oidc.token_invalid" + I18nOIDCTokenInvalidRoleAdmin = "oidc.role_admin_err" + I18nOIDCTokenInvalidRoleUser = "oidc.role_user_err" + I18nOIDCErrGetUser = "oidc.get_user_err" ) // NewI18nError returns a I18nError wrappring the provided error -func NewI18nError(err error, message string) *I18nError { +func NewI18nError(err error, message string, options ...I18nErrorOption) *I18nError { var errI18n *I18nError if errors.As(err, &errI18n) { return errI18n } - return &I18nError{ - err: err, - I18nMessage: message, + errI18n = &I18nError{ + err: err, + Message: message, + args: nil, + } + for _, opt := range options { + opt(errI18n) + } + return errI18n +} + +// I18nErrorOption defines a functional option type that allows to configure the I18nError. +type I18nErrorOption func(*I18nError) + +// I18nErrorArgs is a functional option to set I18nError arguments. +func I18nErrorArgs(args map[string]any) I18nErrorOption { + return func(e *I18nError) { + e.args = args } } // I18nError is an error wrapper that add a message to use for localization. type I18nError struct { - err error - I18nMessage string + err error + Message string + args map[string]any } // Error returns the wrapped error string. @@ -161,6 +187,11 @@ func (e *I18nError) Error() string { return e.err.Error() } +// Unwrap returns the underlying error +func (e *I18nError) Unwrap() error { + return e.err +} + // Is reports if target matches func (e *I18nError) Is(target error) bool { if errors.Is(e.err, target) { @@ -169,3 +200,19 @@ func (e *I18nError) Is(target error) bool { _, ok := target.(*I18nError) return ok } + +// HasArgs returns true if the error has i18n args. +func (e *I18nError) HasArgs() bool { + return len(e.args) > 0 +} + +// Args returns the provided args in JSON format +func (e *I18nError) Args() string { + if len(e.args) > 0 { + data, err := json.Marshal(e.args) + if err == nil { + return string(data) + } + } + return "{}" +} diff --git a/static/locales/en/translation.json b/static/locales/en/translation.json index 28fe8326..854aac04 100644 --- a/static/locales/en/translation.json +++ b/static/locales/en/translation.json @@ -19,10 +19,11 @@ "download_shared_file": "Download shared file", "share_access_error": "Unable to access the share", "invalid_auth_request": "Invalid authentication request", - "error429": "Too Many Requests", - "error403": "Forbidden", "error400": "Bad Request", + "error403": "Forbidden", + "error404": "Not Found", "error416": "Requested Range Not Satisfiable", + "error429": "Too Many Requests", "error500": "Internal Server Error", "errorPDF": "Unable to show PDF file", "error_editor": "Cannot open file editor" @@ -52,7 +53,7 @@ "reset_pwd_err_generic": "Unexpected error while resetting password", "reset_ok_login_error": "The password reset completed successfully but an unexpected error occurred while signing in", "ip_not_allowed": "Login is not allowed from this IP address", - "two_factor_required": "Two-factor authentication is required, set it up" + "two_factor_required": "Set up two-factor authentication, it is required for the following protocols: {{val}}" }, "theme": { "light": "Light", @@ -281,9 +282,9 @@ "auth_code_invalid": "Failed to validate the provided authentication code", "auth_secret_gen_err": "Failed to generate authentication secret", "save_err": "Failed to save configuration", - "save_err_proto": "$t(2fa.save_err). Make sure the protocols enabled comply with company policy", "auth_code_required": "The authentication code is required", - "no_protocol": "Please select at least a protocol" + "no_protocol": "Please select at least a protocol", + "required_protocols": "The following protocols are required: {{val}}" }, "share": { "scope": "Scope", @@ -309,7 +310,7 @@ "max_tokens_invalid": "Invalid max tokens", "expiration_invalid": "Invalid expiration", "err_no_password": "You are not allowed to share files/folders without password", - "expiration_out_of_range": "Set an expiration date and ensure it complies with company policy, e.g. is not too far in the future", + "expiration_out_of_range": "Set an expiration date and make sure it is less than or equal to {{- val, datetime}}", "generic": "Unexpected error saving share", "path_required": "At least a path is required", "path_write_scope": "The write scope requires exactly one path", @@ -366,5 +367,15 @@ "file_pattern_invalid": "Invalid file name pattern filters", "disable_active_2fa": "Two-factor authentication cannot be disabled for a user with an active configuration", "pwd_change_conflict": "It is not possible to request a password change and at the same time prevent the password from being changed" + }, + "oidc": { + "token_expired": "Your OpenID token has expired, please log in again", + "token_invalid_webadmin": "Your OpenID token is not valid for the WebAdmin UI. Log out of your OpenID server and log in to WebAdmin", + "token_invalid_webclient": "Your OpenID token is not valid for the WebClient UI. Log out of your OpenID server and log in to the WebClient", + "token_exchange_err": "Failed to exchange OpenID token", + "token_invalid": "Invalid OpenID token", + "role_admin_err": "Incorrect OpenID role, logged in user is not an administrator", + "role_user_err": "Incorrect OpenID role, logged in user is an administrator", + "get_user_err": "Failed to get user associated with OpenID token" } } \ No newline at end of file diff --git a/static/locales/it/translation.json b/static/locales/it/translation.json index bec4b0f9..ace64143 100644 --- a/static/locales/it/translation.json +++ b/static/locales/it/translation.json @@ -19,10 +19,11 @@ "download_shared_file": "Scarica file condiviso", "share_access_error": "Impossibile accedere alla condivisione", "invalid_auth_request": "Richiesta di autenticazione non valida", - "error429": "Troppe richieste", - "error403": "Non permesso", "error400": "Richiesta non valida", + "error403": "Non permesso", + "error404": "Non trovato", "error416": "Impossibile tornare l'intervallo richiesto", + "error429": "Troppe richieste", "error500": "Errore interno del server", "errorPDF": "Impossibile mostrare il file PDF", "error_editor": "Impossibile aprire l'editor di file" @@ -52,7 +53,7 @@ "reset_pwd_err_generic": "Errore imprevisto durante la reimpostazione della password", "reset_ok_login_error": "La reimpostazione della password è stata completata correttamente ma si è verificato un errore imprevisto durante l'accesso", "ip_not_allowed": "L'accesso non è consentito da questo indirizzo IP", - "two_factor_required": "È richiesta l'autenticazione a due fattori, configurala" + "two_factor_required": "Configura l'autenticazione a due fattori, è obbligatoria per i seguenti protocolli: {{val}}" }, "theme": { "light": "Chiaro", @@ -281,9 +282,9 @@ "auth_code_invalid": "Impossibile convalidare il codice di autenticazione fornito", "auth_secret_gen_err": "Impossibile generare il segreto di autenticazione", "save_err": "Impossibile salvare la configurazione", - "save_err_proto": "$t(2fa.save_err). Assicurati che i protocolli abilitati siano conformi alla politica aziendale", "auth_code_required": "Il codice di autenticazione è obbligatorio", - "no_protocol": "Seleziona almeno un protocollo" + "no_protocol": "Seleziona almeno un protocollo", + "required_protocols": "I seguenti protocolli sono obbligatori: {{val}}" }, "share": { "scope": "Ambito", @@ -309,7 +310,7 @@ "max_tokens_invalid": "Token massimi non validi", "expiration_invalid": "Scadenza non valida", "err_no_password": "Non sei autorizzato a condividere file/cartelle senza password", - "expiration_out_of_range": "Imposta una data di scadenza e assicurati che sia conforme alla politica aziendale, ad es. non è troppo lontan nel futuro", + "expiration_out_of_range": "Imposta una data di scadenza e assicurati che sia inferiore o uguale al {{- val, datetime}}", "generic": "Errore imprevisto durante il salvataggio della condivisione", "path_required": "È necessario almeno un percorso", "path_write_scope": "L'ambito di scrittura richiede esattamente un percorso", @@ -366,5 +367,15 @@ "file_pattern_invalid": "Filtri su modelli di nome file non validi", "disable_active_2fa": "L'autenticazione a due fattori non può essere disabilitata per un utente con una configurazione attiva", "pwd_change_conflict": "Non è possibile richiedere la modifica della password e allo stesso tempo impedire la modifica della password" + }, + "oidc": { + "token_expired": "Il tuo token OpenID è scaduto, effettua nuovamente l'accesso", + "token_invalid_webadmin": "Il tuo token OpenID non è valido per l'interfaccia utente WebAdmin. Esci dal tuo server OpenID e accedi a WebAdmin", + "token_invalid_webclient": "Il tuo token OpenID non è valido per l'interfaccia utente WebClient. Esci dal tuo server OpenID e accedi al WebClient", + "token_exchange_err": "Impossibile scambiare il token OpenID", + "token_invalid": "Token OpenID non valido", + "role_admin_err": "Ruolo OpenID errato, l'utente che ha effettuato l'accesso non è un amministratore", + "role_user_err": "Ruolo OpenID errato, l'utente che ha effettuato l'accesso è un amministratore", + "get_user_err": "Impossibile ottenere l'utente associato al token OpenID" } } \ No newline at end of file diff --git a/templates/common/base.html b/templates/common/base.html index e46f5937..a52a0720 100644 --- a/templates/common/base.html +++ b/templates/common/base.html @@ -21,7 +21,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
- +