web: add support of internatialization (#11) (#16)

This commit is contained in:
Nikolay Stupak 2021-03-05 18:24:45 +03:00 committed by GitHub
parent b0d037cca9
commit 46ef5bd493
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 444 additions and 482 deletions

View File

@ -166,13 +166,96 @@ After that you should set the directory path to the environment variable `WERTHE
### Custom login page ### Custom login page
A login page's template must be a Go template. The template has access to data conforming the next JSON-schema:
```yaml
type: object
properties:
- WebBasePath:
description: The base path of the login page
type: string
- LangPrefs:
description: The user language preferences (the parsed value of the header Accept-Language)
type: array
items:
type: object
properties:
- Lang:
description: The language canonical name.
type: string
- Weight:
description: The language weight.
type: number
required:
- Lang
- Weight
- Data:
type: object
properties:
- CSRFToken:
description: A CSRF token.
type: string
- Challenge:
description: A login challenge ID.
type: string
- LoginURL:
description: An endpoint that finishes the login process.
type: string
- IsInvalidCredentials:
description: Specifies that a user types an invalid username or password.
type: boolean
- IsInternalError:
description: Specifies that an internal server error happens when finishing the login process.
type: boolean
required:
- CSRFToken
- Challenge
- LoginURL
- IsInvalidCredentials
- IsInternalError
required:
- WebBasePath
- LangPrefs
- Data
```
When a login page's template contains static resources (like styles, scripts, and images)
they must be placed in a subdirectory called `static`.
For a full example of a login page's template see [source code](internal/web/templates).
### Custom login page (old format)
*The old template format is also supported but it will be removed in the future major release.*
A login page's template should contains blocks `title`, `style`, `script`, `content`. A login page's template should contains blocks `title`, `style`, `script`, `content`.
Each block has access to data that is an object with the next properties: Each block has access to data conforming the next JSON-schema:
- `CSRFToken` (string) - a CSRF token;
- `Challenge` (string) - a login challenge ID; ```yaml
- `LoginURL` (string) - an endpoint that finishes the login process; type: object
- `IsInvalidCredentials` (bool) - specifies that a user types an invalid username or password; properties:
- `IsInternalError` (bool) specifies that an internal server error happens when finishing the login process. - CSRFToken:
description: A CSRF token.
type: string
- Challenge:
description: A login challenge ID.
type: string
- LoginURL:
description: An endpoint that finishes the login process.
type: string
- IsInvalidCredentials:
description: Specifies that a user types an invalid username or password.
type: boolean
- IsInternalError:
description: Specifies that an internal server error happens when finishing the login process.
type: boolean
required:
- CSRFToken
- Challenge
- LoginURL
- IsInvalidCredentials
- IsInternalError
```
When a login page's template contains static resources (like styles, scripts, and images) When a login page's template contains static resources (like styles, scripts, and images)
they must be placed in a subdirectory called `static`. they must be placed in a subdirectory called `static`.

2
go.mod
View File

@ -17,7 +17,7 @@ require (
github.com/sergi/go-diff v1.0.0 // indirect github.com/sergi/go-diff v1.0.0 // indirect
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 // indirect github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 // indirect
go.uber.org/zap v1.10.0 go.uber.org/zap v1.10.0
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 // indirect golang.org/x/text v0.3.2
) )
go 1.13 go 1.13

5
go.sum
View File

@ -57,5 +57,6 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 h1:JBwmEvLfCqgPcIq8MjVMQxsF3LVL4XG/HH0qiG0+IFY= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@ -52,7 +52,7 @@ type oidcClaimsFinder interface {
// TemplateRenderer renders a template with data and writes it to a http.ResponseWriter. // TemplateRenderer renders a template with data and writes it to a http.ResponseWriter.
type TemplateRenderer interface { type TemplateRenderer interface {
RenderTemplate(w http.ResponseWriter, name string, data interface{}) error RenderTemplate(w http.ResponseWriter, r *http.Request, name string, data interface{}) error
} }
// LoginTmplData is a data that is needed for rendering the login page. // LoginTmplData is a data that is needed for rendering the login page.
@ -147,7 +147,7 @@ func newLoginStartHandler(rproc oa2LoginReqProcessor, tmplRenderer TemplateRende
Challenge: challenge, Challenge: challenge,
LoginURL: strings.TrimPrefix(r.URL.String(), "/"), LoginURL: strings.TrimPrefix(r.URL.String(), "/"),
} }
if err := tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil { if err := tmplRenderer.RenderTemplate(w, r, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err)) log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
@ -180,7 +180,7 @@ func newLoginEndHandler(ra oa2LoginReqAcceptor, auther authenticator, tmplRender
data.IsInternalError = true data.IsInternalError = true
log.Infow("Failed to authenticate a login request via the OAuth2 provider", log.Infow("Failed to authenticate a login request via the OAuth2 provider",
zap.Error(err), "challenge", challenge, "username", username) zap.Error(err), "challenge", challenge, "username", username)
if err = tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil { if err = tmplRenderer.RenderTemplate(w, r, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err)) log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
@ -188,7 +188,7 @@ func newLoginEndHandler(ra oa2LoginReqAcceptor, auther authenticator, tmplRender
case !ok: case !ok:
data.IsInvalidCredentials = true data.IsInvalidCredentials = true
log.Debugw("Invalid credentials", zap.Error(err), "challenge", challenge, "username", username) log.Debugw("Invalid credentials", zap.Error(err), "challenge", challenge, "username", username)
if err = tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil { if err = tmplRenderer.RenderTemplate(w, r, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err)) log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
@ -201,7 +201,7 @@ func newLoginEndHandler(ra oa2LoginReqAcceptor, auther authenticator, tmplRender
if err != nil { if err != nil {
data.IsInternalError = true data.IsInternalError = true
log.Infow("Failed to accept a login request via the OAuth2 provider", zap.Error(err)) log.Infow("Failed to accept a login request via the OAuth2 provider", zap.Error(err))
if err := tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil { if err := tmplRenderer.RenderTemplate(w, r, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err)) log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }

View File

@ -98,7 +98,7 @@ func TestHandleLoginStart(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
tmplRenderer := &testTemplateRenderer{ tmplRenderer := &testTemplateRenderer{
renderTmplFunc: func(w http.ResponseWriter, name string, data interface{}) error { renderTmplFunc: func(w http.ResponseWriter, r *http.Request, name string, data interface{}) error {
if name != "login.tmpl" { if name != "login.tmpl" {
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name) t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
} }
@ -264,7 +264,7 @@ func TestHandleLoginEnd(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
tmplRenderer := &testTemplateRenderer{ tmplRenderer := &testTemplateRenderer{
renderTmplFunc: func(w http.ResponseWriter, name string, data interface{}) error { renderTmplFunc: func(w http.ResponseWriter, r *http.Request, name string, data interface{}) error {
if name != "login.tmpl" { if name != "login.tmpl" {
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name) t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
} }
@ -327,11 +327,11 @@ func TestHandleLoginEnd(t *testing.T) {
} }
type testTemplateRenderer struct { type testTemplateRenderer struct {
renderTmplFunc func(w http.ResponseWriter, name string, data interface{}) error renderTmplFunc func(w http.ResponseWriter, r *http.Request, name string, data interface{}) error
} }
func (tl *testTemplateRenderer) RenderTemplate(w http.ResponseWriter, name string, data interface{}) error { func (tl *testTemplateRenderer) RenderTemplate(w http.ResponseWriter, r *http.Request, name string, data interface{}) error {
return tl.renderTmplFunc(w, name, data) return tl.renderTmplFunc(w, r, name, data)
} }
type testAuthenticator struct { type testAuthenticator struct {

File diff suppressed because one or more lines are too long

View File

@ -1,30 +1,26 @@
{{ define "title" }} <!DOCTYPE html>
Login Provider Werther <html lang="{{ (index .LangPrefs 0).Lang }}">
{{ end }} <head>
<meta name="viewport" content="width=device-width, initial-scale=1">
{{ define "style" }} <title>Login Provider Werther</title>
<base href="{{ .WebBasePath }}">
<link rel="stylesheet" href="static/style.css"> <link rel="stylesheet" href="static/style.css">
{{ end }} </head>
<body>
{{ define "js" }}
<script type="text/javascript" src="static/script.js"></script>
{{ end }}
{{ define "content" }}
<div class="login-page"> <div class="login-page">
<div class="form"> <div class="form">
<p class="message"> <p class="message">
{{ if .IsInvalidCredentials }} {{ if .Data.IsInvalidCredentials }}
Invalid username or password Invalid username or password
{{ else if .IsInternalError }} {{ else if .Data.IsInternalError }}
Internal server error Internal server error
{{ else }} {{ else }}
&nbsp; &nbsp;
{{ end }} {{ end }}
</p> </p>
<form class="login-form" action="{{ .LoginURL }}" method="POST"> <form class="login-form" action="{{ .Data.LoginURL }}" method="POST">
<input type="hidden" name="csrf_token" value={{ .CSRFToken }}> <input type="hidden" name="csrf_token" value="{{ .Data.CSRFToken }}">
<input type="hidden" name="login_challenge" value={{ .Challenge }}> <input type="hidden" name="login_challenge" value="{{ .Data.Challenge }}">
<input type="text" placeholder="username" name="username"/> <input type="text" placeholder="username" name="username"/>
<input type="password" placeholder="password" name="password"/> <input type="password" placeholder="password" name="password"/>
@ -42,4 +38,6 @@
</form> </form>
</div> </div>
</div> </div>
{{ end }} <script type="text/javascript" src="static/script.js"></script>
</body>
</html>

View File

@ -1,32 +1,10 @@
external template external template
WebBasePath: testBasePath; WebBasePath: testBasePath;
Title: Langs:
ru-RU;q=1,ru;q=0.9,en-US;q=0.8,en;q=0.7,
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Style:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Js:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Content:
Data:
CSRFToken: testCSRFToken; CSRFToken: testCSRFToken;
Challenge: testChalenge; Challenge: testChalenge;
LoginURL: testLoginURL; LoginURL: testLoginURL;

View File

@ -1,31 +1,13 @@
{{- define "title" }} {{- define "main" }}external template
CSRFToken: {{ .CSRFToken }}; WebBasePath: {{ .WebBasePath }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "style" }} Langs:
CSRFToken: {{ .CSRFToken }}; {{ range .LangPrefs }}{{ .Lang }};q={{ .Weight }},{{ end }}
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "js" }} Data:
CSRFToken: {{ .CSRFToken }}; CSRFToken: {{ .Data.CSRFToken }};
Challenge: {{ .Challenge }}; Challenge: {{ .Data.Challenge }};
LoginURL: {{ .LoginURL }}; LoginURL: {{ .Data.LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }}; IsInvalidCredentials: {{ .Data.IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }}; IsInternalError: {{ .Data.IsInternalError }};
{{- end }}
{{- define "content" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }} {{- end }}

View File

@ -1,32 +1,10 @@
internal template internal template
WebBasePath: testBasePath; WebBasePath: testBasePath;
Title: Langs:
ru-RU;q=1,ru;q=0.9,en-US;q=0.8,en;q=0.7,
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Style:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Js:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Content:
Data:
CSRFToken: testCSRFToken; CSRFToken: testCSRFToken;
Challenge: testChalenge; Challenge: testChalenge;
LoginURL: testLoginURL; LoginURL: testLoginURL;

View File

@ -1,31 +1,13 @@
{{- define "title" }} {{- define "main" }}internal template
CSRFToken: {{ .CSRFToken }}; WebBasePath: {{ .WebBasePath }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "style" }} Langs:
CSRFToken: {{ .CSRFToken }}; {{ range .LangPrefs }}{{ .Lang }};q={{ .Weight }},{{ end }}
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "js" }} Data:
CSRFToken: {{ .CSRFToken }}; CSRFToken: {{ .Data.CSRFToken }};
Challenge: {{ .Challenge }}; Challenge: {{ .Data.Challenge }};
LoginURL: {{ .LoginURL }}; LoginURL: {{ .Data.LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }}; IsInvalidCredentials: {{ .Data.IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }}; IsInternalError: {{ .Data.IsInternalError }};
{{- end }}
{{- define "content" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }} {{- end }}

View File

@ -0,0 +1,37 @@
external template
WebBasePath: testBasePath;
Langs:
ru-RU;q=1,ru;q=0.9,en-US;q=0.8,en;q=0.7,
Title:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Style:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Js:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Content:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;

View File

@ -0,0 +1,31 @@
{{- define "title" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "style" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "js" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "content" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}

View File

@ -1,6 +1,9 @@
{{- define "main" }}external template {{- define "main" }}external template
WebBasePath: {{ .WebBasePath }}; WebBasePath: {{ .WebBasePath }};
Langs:
{{ range .LangPrefs }}{{ .Lang }};q={{ .Weight }},{{ end }}
Title: Title:
{{ block "title" .Data }}{{ end }} {{ block "title" .Data }}{{ end }}

View File

@ -0,0 +1,37 @@
internal template
WebBasePath: testBasePath;
Langs:
ru-RU;q=1,ru;q=0.9,en-US;q=0.8,en;q=0.7,
Title:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Style:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Js:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Content:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;

View File

@ -0,0 +1,31 @@
{{- define "title" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "style" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "js" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "content" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}

View File

@ -1,6 +1,9 @@
{{- define "main" }}internal template {{- define "main" }}internal template
WebBasePath: {{ .WebBasePath }}; WebBasePath: {{ .WebBasePath }};
Langs:
{{ range .LangPrefs }}{{ .Lang }};q={{ .Weight }},{{ end }}
Title: Title:
{{ block "title" .Data }}{{ end }} {{ block "title" .Data }}{{ end }}

View File

@ -22,6 +22,7 @@ import (
assetfs "github.com/elazarl/go-bindata-assetfs" assetfs "github.com/elazarl/go-bindata-assetfs"
"github.com/i-core/routegroup" "github.com/i-core/routegroup"
"github.com/pkg/errors" "github.com/pkg/errors"
"golang.org/x/text/language"
) )
// The file systems provide templates and their resources that are stored in the application's internal assets. // The file systems provide templates and their resources that are stored in the application's internal assets.
@ -74,8 +75,14 @@ func NewHTMLRenderer(cnf Config) (*HTMLRenderer, error) {
return &HTMLRenderer{Config: cnf, mainTmpl: mainTmpl, fs: fs}, nil return &HTMLRenderer{Config: cnf, mainTmpl: mainTmpl, fs: fs}, nil
} }
type langPref struct {
Lang string
Weight float32
}
// RenderTemplate renders a HTML page from a template with the specified name using the specified data. // RenderTemplate renders a HTML page from a template with the specified name using the specified data.
func (r *HTMLRenderer) RenderTemplate(w http.ResponseWriter, name string, data interface{}) error { func (r *HTMLRenderer) RenderTemplate(w http.ResponseWriter, req *http.Request, name string, data interface{}) error {
// Read and parse the requested template.
f, err := r.fs.Open(name) f, err := r.fs.Open(name)
if err != nil { if err != nil {
if v, ok := err.(*os.PathError); ok { if v, ok := err.(*os.PathError); ok {
@ -89,20 +96,56 @@ func (r *HTMLRenderer) RenderTemplate(w http.ResponseWriter, name string, data i
if err != nil { if err != nil {
return fmt.Errorf("failed to read template %q: %s", name, err) return fmt.Errorf("failed to read template %q: %s", name, err)
} }
t, err := r.mainTmpl.Clone() root, err := template.New("main").Parse(string(b))
if err != nil {
return errors.Wrapf(err, "failed to clone the main template for template %q: %s", name, err)
}
t, err = t.Parse(string(b))
if err != nil { if err != nil {
return errors.Wrapf(err, "failed to parse template %q: %s", name, err) return errors.Wrapf(err, "failed to parse template %q: %s", name, err)
} }
// The old-style template of a web page showed itself as not flexible.
// It was changed with a new template that allows overriding the whole page.
// The old-style template left for backward compatibility
// and will be deprecated in the future major release.
if isOldStyleUserTemplate(root) {
var wrapper *template.Template
wrapper, err = r.mainTmpl.Clone()
if err != nil {
return errors.Wrapf(err, "failed to clone the main template for template %q: %s", name, err)
}
root, err = root.AddParseTree("main", wrapper.Tree)
if err != nil {
return errors.Wrapf(err, "failed to create the main template for template %q: %s", name, err)
}
}
// Prepare template data.
basePath := r.BasePath
if basePath == "" {
basePath = "/"
}
var langPrefs []langPref
if acceptLang := req.Header.Get(http.CanonicalHeaderKey("Accept-Language")); acceptLang != "" {
var tags []language.Tag
var weights []float32
tags, weights, err = language.ParseAcceptLanguage(acceptLang)
if err != nil {
return errors.Wrapf(err, "failed to parse the header \"Accept-Language\": %s", err)
}
for i, tag := range tags {
langPrefs = append(langPrefs, langPref{Lang: tag.String(), Weight: weights[i]})
}
} else {
langPrefs = []langPref{{Lang: "en", Weight: 1}}
}
tmplData := map[string]interface{}{"WebBasePath": basePath, "LangPrefs": langPrefs, "Data": data}
// Render the template.
var ( var (
buf bytes.Buffer buf bytes.Buffer
bw = bufio.NewWriter(&buf) bw = bufio.NewWriter(&buf)
) )
if err = t.Execute(bw, map[string]interface{}{"WebBasePath": r.BasePath, "Data": data}); err != nil { if err = root.Execute(bw, tmplData); err != nil {
return err return err
} }
if err = bw.Flush(); err != nil { if err = bw.Flush(); err != nil {
@ -111,16 +154,38 @@ func (r *HTMLRenderer) RenderTemplate(w http.ResponseWriter, name string, data i
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, err = buf.WriteTo(w) _, err = buf.WriteTo(w)
return err return err
}
// Returns true if a template is the old-style template.
//
// A template is considered as the old-style template
// if it contains four blocks for customizing the page title,
// styles, markup, and scripts.
//
// See https://github.com/i-core/werther/issues/11.
func isOldStyleUserTemplate(root *template.Template) bool {
var tmpls []string
for _, tmpl := range root.Templates() {
tmpls = append(tmpls, tmpl.Name())
}
contains := func(arr []string, tgt string) bool {
for _, item := range arr {
if item == tgt {
return true
}
}
return false
}
return contains(tmpls, "title") && contains(tmpls, "style") && contains(tmpls, "js") && contains(tmpls, "content")
} }
var mainT = `{{ define "main" }} var mainT = `{{ define "main" }}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="{{ (index .LangPrefs 0).Lang }}">
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" .Data }}{{ end }}</title> <title>{{ block "title" .Data }}{{ end }}</title>
<base href={{ .WebBasePath }}> <base href="{{ .WebBasePath }}">
{{ block "style" .Data }}{{ end }} {{ block "style" .Data }}{{ end }}
</head> </head>
<body> <body>

View File

@ -60,23 +60,64 @@ func TestHTMLRenderer(t *testing.T) {
"IsInternalError": true, "IsInternalError": true,
}, },
}, },
{
name: "old style internal template not found",
wantErr: fmt.Errorf(`the template "login.tmpl" does not exist`),
},
{
name: "old style internal template happy path",
basePath: "testBasePath",
data: map[string]interface{}{
"CSRFToken": "testCSRFToken",
"Challenge": "testChalenge",
"LoginURL": "testLoginURL",
"IsInvalidCredentials": true,
"IsInternalError": true,
},
},
{
name: "old style external template not found",
ext: true,
wantErr: fmt.Errorf(`the template "login.tmpl" does not exist`),
},
{
name: "old style external template happy path",
ext: true,
basePath: "testBasePath",
data: map[string]interface{}{
"CSRFToken": "testCSRFToken",
"Challenge": "testChalenge",
"LoginURL": "testLoginURL",
"IsInvalidCredentials": true,
"IsInternalError": true,
},
},
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
tstDir := path.Join("testdata", t.Name()) tstDir := path.Join("testdata", t.Name())
// Read the main template.
var originMainT = mainT var originMainT = mainT
defer func() { mainT = originMainT }() defer func() { mainT = originMainT }()
f, err := os.Open(path.Join(tstDir, "main.tmpl"))
if err != nil { // Read the main template if it is exist.
fpath := path.Join(tstDir, "main.tmpl")
stat, err := os.Stat(fpath)
if err != nil && !os.IsNotExist(err) {
t.Fatalf("failed to open main template: %s", err) t.Fatalf("failed to open main template: %s", err)
} }
fc, err := ioutil.ReadAll(f) if stat != nil {
if err != nil { var f *os.File
if f, err = os.Open(fpath); err != nil {
t.Fatalf("failed to open main template: %s", err)
}
var fc []byte
if fc, err = ioutil.ReadAll(f); err != nil {
t.Fatalf("failed to read main template: %s", err) t.Fatalf("failed to read main template: %s", err)
} }
mainT = string(fc) mainT = string(fc)
}
// Create the template renderer. // Create the template renderer.
cnf := Config{BasePath: tc.basePath} cnf := Config{BasePath: tc.basePath}
@ -93,7 +134,9 @@ func TestHTMLRenderer(t *testing.T) {
} }
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
err = r.RenderTemplate(rr, "login.tmpl", tc.data) req := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
req.Header.Set(http.CanonicalHeaderKey("Accept-Language"), "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7")
err = r.RenderTemplate(rr, req, "login.tmpl", tc.data)
if tc.wantErr != nil { if tc.wantErr != nil {
if err == nil { if err == nil {
@ -107,11 +150,11 @@ func TestHTMLRenderer(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("\ngot error\n\t%s\nwant no errors", err) t.Fatalf("\ngot error\n\t%s\nwant no errors", err)
} }
f, err = os.Open(path.Join(tstDir, "golden.file")) f, err := os.Open(path.Join(tstDir, "golden.file"))
if err != nil { if err != nil {
t.Fatalf("failed to open golden file: %s", err) t.Fatalf("failed to open golden file: %s", err)
} }
fc, err = ioutil.ReadAll(f) fc, err := ioutil.ReadAll(f)
if err != nil { if err != nil {
t.Fatalf("failed to read golden file: %s", err) t.Fatalf("failed to read golden file: %s", err)
} }