build a simple web framework. (#691)

Added the basics for HTML templates, serving assets and so on.
This commit is contained in:
Victor Vrantchan
2020-07-17 12:41:33 -04:00
committed by GitHub
parent 7e811b8e56
commit c71958384e
9 changed files with 342 additions and 0 deletions

View File

@@ -7,10 +7,12 @@ import (
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/oklog/run"
"github.com/peterbourgon/ff/v3"
@@ -28,15 +30,24 @@ func writePID(path string) error {
return nil
}
type cliFlags struct {
siteName string
http string
}
func micromdm(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
var (
ctx = context.Background()
logger = log.New(log.Output(stderr))
cli = &cliFlags{}
rootfs = flag.NewFlagSet("micromdm", flag.ContinueOnError)
pidfile = rootfs.String("pidfile", "/tmp/micromdm.pid", "Path to server pidfile")
_ = rootfs.String("config", "", "Path to config file (optional)")
)
rootfs.StringVar(&cli.siteName, "site_name", "Acme", "Name of the site as it would appear in the top left of the HTML UI")
rootfs.StringVar(&cli.http, "http", "localhost:9000", "HTTP service address")
// default output is os.Stderr.
// setting the output and flag.ContinueOnError overrides allows testing usage.
rootfs.SetOutput(stderr)
@@ -54,6 +65,7 @@ func micromdm(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
// add a help subcommand to make usage more discoverable.
helpCmd := &ffcli.Command{
Name: "help",
ShortHelp: "Print this help text.",
UsageFunc: func(c *ffcli.Command) string { return "" },
Exec: func(_ context.Context, args []string) error {
rootfs.Usage()
@@ -71,11 +83,30 @@ func micromdm(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return err
}
srv, err := setup(cli, logger)
if err != nil {
return err
}
// run.Group manages lifecycles of various long running goroutines:
// - signal handlers for SIGTERM/SIGHUP etc.
// - http.Server listeners.
var g run.Group
{
server := &http.Server{
Handler: srv.ui.Handler(),
Addr: cli.http,
}
g.Add(func() error {
log.Info(logger).Log("component", "frontend", "msg", "started")
return server.ListenAndServe()
}, func(error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
server.Shutdown(ctx)
})
}
{
// when the binary receives SIGINT or SIGTERM, execution is cancelled
ctx, cancel := context.WithCancel(ctx)

27
cmd/micromdm/server.go Normal file
View File

@@ -0,0 +1,27 @@
package main
import (
"micromdm.io/v2/pkg/frontend"
"micromdm.io/v2/pkg/log"
)
type server struct {
ui *frontend.Server
}
func ui(f *cliFlags, logger log.Logger) (*frontend.Server, error) {
return frontend.New(frontend.Config{
Logger: logger,
SiteName: f.siteName,
})
}
func setup(f *cliFlags, logger log.Logger) (*server, error) {
uisrv, err := ui(f, logger)
if err != nil {
return nil, err
}
srv := &server{ui: uisrv}
return srv, nil
}

230
pkg/frontend/frontend.go Normal file
View File

@@ -0,0 +1,230 @@
// Package frontend provides a lightweight framework for building the MicroMDM HTML UI.
package frontend
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"path/filepath"
"runtime/debug"
"sync"
"text/template"
"github.com/gorilla/mux"
"micromdm.io/v2/pkg/log"
)
// Data keys. Private, set via helper methods.
const (
dHTTPCode = "http-code"
dLogErr = "log-error"
dLogKV = "log-keyvals"
dFormErrs = "errors"
dFormAlert = "alert"
)
// Data provides request parameters when calling RenderTemplate.
type Data map[string]interface{}
// WithLog adds keyvals to log when rendering a template.
func (d Data) WithLog(err error, keyvals ...interface{}) Data {
d[dLogErr] = err
d[dLogKV] = keyvals
return d
}
// WithCode sets an HTTP status code. The default value when not set is 200 OK.
func (d Data) WithCode(code int) Data {
d[dHTTPCode] = code
return d
}
// FormErrors adds an "errors" key with a mapping of form fields names to error messages.
// FormErrors sets the HTTP status code to 400 StatusBadRequest.
func (d Data) FormErrors(errs map[string]string) Data {
d[dFormErrs] = errs
return d.WithCode(http.StatusBadRequest)
}
// Framework specifies methods frontend sub-packages depend on.
// Framework is mainly exported to give sub-packages a common interface to depend on.
// Server is the only used Framework implementation.
type Framework interface {
Fail(ctx context.Context, w http.ResponseWriter, err error, keyvals ...interface{})
RenderTemplate(ctx context.Context, w http.ResponseWriter, name string, data Data)
HandleFunc(path string, f func(http.ResponseWriter, *http.Request), methods ...string)
}
// Server implements Framework.
type Server struct {
r *mux.Router
mu sync.Mutex
templates map[string]*template.Template
siteName string
}
// Config parameters to create a new Server.
type Config struct {
Logger log.Logger
SiteName string
}
// New creates a Server.
func New(config Config) (*Server, error) {
srv := &Server{
r: mux.NewRouter(),
templates: make(map[string]*template.Template),
siteName: config.SiteName,
}
srv.r.Use(
log.HTTP(config.Logger), // HTTP logging middleware.
srv.recoverPanic, // convert any panic into 500 errors.
)
// have to set middleware for NotFoundHandler separate from matched routes.
srv.r.NotFoundHandler = log.HTTP(config.Logger)(http.HandlerFunc(srv.notFound))
srv.r.HandleFunc("/", srv.indexPage).Methods(http.MethodGet)
// Serve all static content.
// This is another place that will need to be improved to serve from a CDN or object store instead.
srv.r.PathPrefix("/assets/").Methods(http.MethodGet).
Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
http.StripPrefix("/assets/", http.FileServer(http.Dir("ui/static"))).ServeHTTP(w, r)
}))
if err := srv.loadTemplates(); err != nil {
return nil, fmt.Errorf("loading ui templates: %s", err)
}
return srv, nil
}
// Handler returns the mux router used by the Server.
func (srv *Server) Handler() http.Handler { return srv.r }
// HandleFunc wraps *mux.Router, allowing other packages to register with the router.
func (srv *Server) HandleFunc(path string, f func(http.ResponseWriter, *http.Request), methods ...string) {
if len(methods) == 0 {
methods = []string{http.MethodGet}
}
srv.r.HandleFunc(path, f).Methods(methods...)
}
// Fail renders the 500 InternalServerError template and logs accordingly.
func (srv *Server) Fail(ctx context.Context, w http.ResponseWriter, err error, keyvals ...interface{}) {
srv.RenderTemplate(ctx, w, "500.tmpl", Data{}.
WithLog(err, keyvals...).
WithCode(http.StatusInternalServerError),
)
}
// RenderTemplate renders HTML templates.
func (srv *Server) RenderTemplate(ctx context.Context, w http.ResponseWriter, name string, data Data) {
logger := log.FromContext(ctx)
data["trace_id"] = log.TraceID(ctx)
data["siteName"] = srv.siteName
if logErr, ok := data[dLogErr]; ok {
var kv []interface{}
kv = append(kv, "err", logErr)
extras, ok := data[dLogKV]
if ok {
kv = append(kv, extras.([]interface{})...)
}
// log the template name, avoiding loops to srv.Fail
if name != "500.tmpl" {
kv = append(kv, "template", name)
}
log.Info(logger).Log(kv...)
}
tmpl, ok := srv.templates[name]
if !ok {
srv.Fail(ctx, w, errors.New("no such template"), "template", name)
return
}
// create a buffer to call ExecuteTemplate with, allowing for extra error handling
// TODO: benchmark for allocations here
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "base.tmpl", data); err != nil && name != "500.tmpl" {
srv.Fail(ctx, w, err, "msg", "executing template", "template", name)
return
} else if err != nil {
log.Info(logger).Log("msg", "500 template failed to render", "err", err)
return
}
if code, ok := data[dHTTPCode]; ok {
w.WriteHeader(code.(int))
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
buf.WriteTo(w)
log.Debug(logger).Log("msg", "rendered template", "template", name)
}
// loadTemplates loads the UI from disk and caches it in a map.
// A lot of the inspiration came from an article I came across:
// https://blog.questionable.services/article/approximating-html-template-inheritance/
// Changes/imporvements to consider:
// - make the layouts/includes locations configurable.
// - allow loading from object storage (gcs/s3) instead of a local disk.
// - support reloading with SIGHUP/other listeners.
// Today, SIGHUP reloads the entire process, which works okay...
func (srv *Server) loadTemplates() error {
layouts, err := filepath.Glob("ui/layouts/*.tmpl")
if err != nil {
return fmt.Errorf("load layouts: %s", err)
}
includes, err := filepath.Glob("ui/includes/*.tmpl")
if err != nil {
return fmt.Errorf("load includes: %s", err)
}
srv.mu.Lock()
defer srv.mu.Unlock()
for _, tpl := range includes {
files := append(layouts, tpl)
srv.templates[filepath.Base(tpl)] = template.Must(
template.New(filepath.Base(tpl)).ParseFiles(files...),
)
}
return nil
}
func (srv *Server) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
srv.Fail(r.Context(), w, fmt.Errorf("panic: %v", err), "msg", "recover panic")
debug.PrintStack()
}
}()
next.ServeHTTP(w, r)
})
}
func (srv *Server) notFound(w http.ResponseWriter, r *http.Request) {
srv.RenderTemplate(r.Context(), w, "404.tmpl", Data{}.WithCode(http.StatusNotFound))
}
func (srv *Server) indexPage(w http.ResponseWriter, r *http.Request) {
srv.RenderTemplate(r.Context(), w, "home.tmpl", Data{})
}

11
ui/includes/404.tmpl Normal file
View File

@@ -0,0 +1,11 @@
{{ define "title"}}
<title>Not Found</title>
{{ end }}
{{ define "content" }}
<div class="error-page">
<h2>404 Not Found</h2>
<p>
The page you're looking for is not here. <a href="/">Return</a>
</p>
</div>
{{ end }}

13
ui/includes/500.tmpl Normal file
View File

@@ -0,0 +1,13 @@
{{ define "title"}}
<title>Internal Server Error</title>
{{ end }}
{{ define "content" }}
<div class="error-page">
{{ with .siteName }}<h1>{{.}}</h1>{{ end }}
<h2>500 Internal Server Error</h2>
<p>
You encountered an unknown error. We are looking into it. <a href="/">Return</a>
{{ with .trace_id }}<p>Error ID: {{.}}</p>{{ end }}
</p>
</div>
{{ end }}

6
ui/includes/home.tmpl Normal file
View File

@@ -0,0 +1,6 @@
{{ define "title" -}}
<title>Home - {{.siteName}}</title>
{{- end }}
{{ define "content" -}}
<h1>Do it!</h1>
{{- end }}

22
ui/layouts/base.tmpl Normal file
View File

@@ -0,0 +1,22 @@
{{- define "base.tmpl" -}}
<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/assets/style.css">
{{ template "title" . }}
</head>
<body>
{{ template "nav.tmpl" . }}
{{ template "scripts" . }}
{{- template "sidebar" . }}
{{- template "content" . }}
<footer></footer>
</body>
</html>
{{ end }}
{{ define "scripts" }}
{{- end }}
{{ define "sidebar" }}
{{- end }}

2
ui/layouts/nav.tmpl Normal file
View File

@@ -0,0 +1,2 @@
<nav class="site-nav">
</nav>

0
ui/static/style.css Normal file
View File