dex/server/templates.go
Eric Chiang 391dc51c13 *: add theme based frontend configuration
This PR reworks the web layout so static files can be provided and
a "themes" directory to allow a certain degree of control over logos,
styles, etc.

This PR does NOT add general support for frontend customization,
only enough to allow us to start exploring theming internally.
The dex binary also must now be run from the root directory since
templates are no longer "compiled into" the binary.

The docker image has been updated with frontend assets.
2016-11-30 17:20:21 -08:00

249 lines
5.9 KiB
Go

package server
import (
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"text/template"
)
const (
tmplApproval = "approval.html"
tmplLogin = "login.html"
tmplPassword = "password.html"
tmplOOB = "oob.html"
)
var requiredTmpls = []string{
tmplApproval,
tmplLogin,
tmplPassword,
tmplOOB,
}
type templates struct {
loginTmpl *template.Template
approvalTmpl *template.Template
passwordTmpl *template.Template
oobTmpl *template.Template
}
type webConfig struct {
dir string
logoURL string
issuer string
theme string
issuerURL string
}
func join(base, path string) string {
b := strings.HasSuffix(base, "/")
p := strings.HasPrefix(path, "/")
switch {
case b && p:
return base + path[1:]
case b || p:
return base + path
default:
return base + "/" + path
}
}
func dirExists(dir string) error {
stat, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("directory %q does not exist", dir)
}
return fmt.Errorf("stat directory %q: %v", dir, err)
}
if !stat.IsDir() {
return fmt.Errorf("path %q is a file not a directory", dir)
}
return nil
}
// loadWebConfig returns static assets, theme assets, and templates used by the frontend by
// reading the directory specified in the webConfig.
//
// The directory layout is expected to be:
//
// ( web directory )
// |- static
// |- themes
// | |- (theme name)
// |- templates
//
func loadWebConfig(c webConfig) (static, theme http.Handler, templates *templates, err error) {
if c.theme == "" {
c.theme = "coreos"
}
if c.issuer == "" {
c.issuer = "dex"
}
if c.dir == "" {
c.dir = "./web"
}
if c.logoURL == "" {
c.logoURL = join(c.issuerURL, "theme/logo.png")
}
if err := dirExists(c.dir); err != nil {
return nil, nil, nil, fmt.Errorf("load web dir: %v", err)
}
staticDir := filepath.Join(c.dir, "static")
templatesDir := filepath.Join(c.dir, "templates")
themeDir := filepath.Join(c.dir, "themes", c.theme)
for _, dir := range []string{staticDir, templatesDir, themeDir} {
if err := dirExists(dir); err != nil {
return nil, nil, nil, fmt.Errorf("load dir: %v", err)
}
}
static = http.FileServer(http.Dir(staticDir))
theme = http.FileServer(http.Dir(themeDir))
templates, err = loadTemplates(c, templatesDir)
return
}
// loadTemplates parses the expected templates from the provided directory.
func loadTemplates(c webConfig, templatesDir string) (*templates, error) {
files, err := ioutil.ReadDir(templatesDir)
if err != nil {
return nil, fmt.Errorf("read dir: %v", err)
}
filenames := []string{}
for _, file := range files {
if file.IsDir() {
continue
}
filenames = append(filenames, filepath.Join(templatesDir, file.Name()))
}
if len(filenames) == 0 {
return nil, fmt.Errorf("no files in template dir %q", templatesDir)
}
funcs := map[string]interface{}{
"issuer": func() string { return c.issuer },
"logo": func() string { return c.logoURL },
"url": func(s string) string { return join(c.issuerURL, s) },
}
tmpls, err := template.New("").Funcs(funcs).ParseFiles(filenames...)
if err != nil {
return nil, fmt.Errorf("parse files: %v", err)
}
missingTmpls := []string{}
for _, tmplName := range requiredTmpls {
if tmpls.Lookup(tmplName) == nil {
missingTmpls = append(missingTmpls, tmplName)
}
}
if len(missingTmpls) > 0 {
return nil, fmt.Errorf("missing template(s): %s", missingTmpls)
}
return &templates{
loginTmpl: tmpls.Lookup(tmplLogin),
approvalTmpl: tmpls.Lookup(tmplApproval),
passwordTmpl: tmpls.Lookup(tmplPassword),
oobTmpl: tmpls.Lookup(tmplOOB),
}, nil
}
var scopeDescriptions = map[string]string{
"offline_access": "Have offline access",
"profile": "View basic profile information",
"email": "View your email",
}
type connectorInfo struct {
ID string
Name string
URL string
}
type byName []connectorInfo
func (n byName) Len() int { return len(n) }
func (n byName) Less(i, j int) bool { return n[i].Name < n[j].Name }
func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
func (t *templates) login(w http.ResponseWriter, connectors []connectorInfo, authReqID string) {
sort.Sort(byName(connectors))
data := struct {
Connectors []connectorInfo
AuthReqID string
}{connectors, authReqID}
renderTemplate(w, t.loginTmpl, data)
}
func (t *templates) password(w http.ResponseWriter, authReqID, callback, lastUsername string, lastWasInvalid bool) {
data := struct {
AuthReqID string
PostURL string
Username string
Invalid bool
}{authReqID, string(callback), lastUsername, lastWasInvalid}
renderTemplate(w, t.passwordTmpl, data)
}
func (t *templates) approval(w http.ResponseWriter, authReqID, username, clientName string, scopes []string) {
accesses := []string{}
for _, scope := range scopes {
access, ok := scopeDescriptions[scope]
if ok {
accesses = append(accesses, access)
}
}
sort.Strings(accesses)
data := struct {
User string
Client string
AuthReqID string
Scopes []string
}{username, clientName, authReqID, accesses}
renderTemplate(w, t.approvalTmpl, data)
}
func (t *templates) oob(w http.ResponseWriter, code string) {
data := struct {
Code string
}{code}
renderTemplate(w, t.oobTmpl, data)
}
// small io.Writer utilitiy to determine if executing the template wrote to the underlying response writer.
type writeRecorder struct {
wrote bool
w io.Writer
}
func (w *writeRecorder) Write(p []byte) (n int, err error) {
w.wrote = true
return w.w.Write(p)
}
func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data interface{}) {
wr := &writeRecorder{w: w}
if err := tmpl.Execute(wr, data); err != nil {
log.Printf("Error rendering template %s: %s", tmpl.Name(), err)
if !wr.wrote {
// TODO(ericchiang): replace with better internal server error.
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
return
}