1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2025-12-18 01:57:02 +00:00

Refactor web package into two packages: httpd and webui

This commit is contained in:
James Hillyerd
2016-02-24 22:18:30 -08:00
parent 0b32af5495
commit 8e084b5697
12 changed files with 112 additions and 92 deletions

56
httpd/context.go Normal file
View File

@@ -0,0 +1,56 @@
package httpd
import (
"net/http"
"strings"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/smtpd"
)
// Context is passed into every request handler function
type Context struct {
Vars map[string]string
Session *sessions.Session
DataStore smtpd.DataStore
IsJSON bool
}
// Close the Context (currently does nothing)
func (c *Context) Close() {
// Do nothing
}
// headerMatch returns true if the request header specified by name contains
// the specified value. Case is ignored.
func headerMatch(req *http.Request, name string, value string) bool {
name = http.CanonicalHeaderKey(name)
value = strings.ToLower(value)
if header := req.Header[name]; header != nil {
for _, hv := range header {
if value == strings.ToLower(hv) {
return true
}
}
}
return false
}
// NewContext returns a Context for the given HTTP Request
func NewContext(req *http.Request) (*Context, error) {
vars := mux.Vars(req)
sess, err := sessionStore.Get(req, "inbucket")
ctx := &Context{
Vars: vars,
Session: sess,
DataStore: DataStore,
IsJSON: headerMatch(req, "Accept", "application/json"),
}
if err != nil {
return ctx, err
}
return ctx, err
}

64
httpd/helpers.go Normal file
View File

@@ -0,0 +1,64 @@
package httpd
import (
"fmt"
"html"
"html/template"
"regexp"
"strings"
"time"
"github.com/jhillyerd/inbucket/log"
)
// TemplateFuncs declares functions made available to all templates (including partials)
var TemplateFuncs = template.FuncMap{
"friendlyTime": FriendlyTime,
"reverse": Reverse,
"textToHtml": TextToHTML,
}
// From http://daringfireball.net/2010/07/improved_regex_for_matching_urls
var urlRE = regexp.MustCompile("(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
// FriendlyTime renders a timestamp in a friendly fashion: 03:04:05 PM if same day,
// otherwise Mon Jan 2, 2006
func FriendlyTime(t time.Time) template.HTML {
ty, tm, td := t.Date()
ny, nm, nd := time.Now().Date()
if (ty == ny) && (tm == nm) && (td == nd) {
return template.HTML(t.Format("03:04:05 PM"))
}
return template.HTML(t.Format("Mon Jan 2, 2006"))
}
// Reverse routing function (shared with templates)
func Reverse(name string, things ...interface{}) string {
// Convert the things to strings
strs := make([]string, len(things))
for i, th := range things {
strs[i] = fmt.Sprint(th)
}
// Grab the route
u, err := Router.Get(name).URL(strs...)
if err != nil {
log.Errorf("Failed to reverse route: %v", err)
return "/ROUTE-ERROR"
}
return u.Path
}
// TextToHTML takes plain text, escapes it and tries to pretty it up for
// HTML display
func TextToHTML(text string) template.HTML {
text = html.EscapeString(text)
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
return template.HTML(replacer.Replace(text))
}
// WrapURL wraps a <a href> tag around the provided URL
func WrapURL(url string) string {
unescaped := strings.Replace(url, "&amp;", "&", -1)
return fmt.Sprintf("<a href=\"%s\" target=\"_blank\">%s</a>", unescaped, url)
}

30
httpd/helpers_test.go Normal file
View File

@@ -0,0 +1,30 @@
package httpd
import (
"html/template"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTextToHtml(t *testing.T) {
// Identity
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
// Check it escapes
assert.Equal(t, TextToHTML("<html>"), template.HTML("&lt;html&gt;"))
// Check for linebreaks
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak"))
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak"))
}
func TestURLDetection(t *testing.T) {
assert.Equal(t,
TextToHTML("http://google.com/"),
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>"))
assert.Equal(t,
TextToHTML("http://a.com/?q=a&n=v"),
template.HTML("<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;n=v</a>"))
}

15
httpd/rest.go Normal file
View File

@@ -0,0 +1,15 @@
package httpd
import (
"encoding/json"
"net/http"
)
// RenderJSON sets the correct HTTP headers for JSON, then writes the specified
// data (typically a struct) encoded in JSON
func RenderJSON(w http.ResponseWriter, data interface{}) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Expires", "-1")
enc := json.NewEncoder(w)
return enc.Encode(data)
}

132
httpd/server.go Normal file
View File

@@ -0,0 +1,132 @@
// Package httpd provides the plumbing for Inbucket's web GUI and RESTful API
package httpd
import (
"fmt"
"net"
"net/http"
"time"
"github.com/goods/httpbuf"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/smtpd"
)
// Handler is a function type that handles an HTTP request in Inbucket
type Handler func(http.ResponseWriter, *http.Request, *Context) error
var (
// DataStore is where all the mailboxes and messages live
DataStore smtpd.DataStore
// Router is shared between httpd, webui and rest packages. It sends
// incoming requests to the correct handler function
Router = mux.NewRouter()
webConfig config.WebConfig
listener net.Listener
sessionStore sessions.Store
shutdown bool
)
// Initialize sets up things for unit tests or the Start() method
func Initialize(cfg config.WebConfig, ds smtpd.DataStore) {
webConfig = cfg
setupRoutes(cfg)
// NewContext() will use this DataStore for the web handlers
DataStore = ds
// TODO Make configurable
sessionStore = sessions.NewCookieStore([]byte("something-very-secret"))
}
func setupRoutes(cfg config.WebConfig) {
log.Infof("HTTP templates mapped to %q", cfg.TemplateDir)
log.Infof("HTTP static content mapped to %q", cfg.PublicDir)
// Static content
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
http.FileServer(http.Dir(cfg.PublicDir))))
// Register w/ HTTP
http.Handle("/", Router)
}
// Start begins listening for HTTP requests
func Start() {
addr := fmt.Sprintf("%v:%v", webConfig.IP4address, webConfig.IP4port)
server := &http.Server{
Addr: addr,
Handler: nil,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
}
// We don't use ListenAndServe because it lacks a way to close the listener
log.Infof("HTTP listening on TCP4 %v", addr)
var err error
listener, err = net.Listen("tcp", addr)
if err != nil {
log.Errorf("HTTP failed to start TCP4 listener: %v", err)
// TODO More graceful early-shutdown procedure
panic(err)
}
err = server.Serve(listener)
if shutdown {
log.Tracef("HTTP server shutting down on request")
} else if err != nil {
log.Errorf("HTTP server failed: %v", err)
}
}
// Stop shuts down the HTTP server
func Stop() {
log.Tracef("HTTP shutdown requested")
shutdown = true
if listener != nil {
if err := listener.Close(); err != nil {
log.Errorf("Error closing HTTP listener: %v", err)
}
} else {
log.Errorf("HTTP listener was nil during shutdown")
}
}
// ServeHTTP builds the context and passes onto the real handler
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Create the context
ctx, err := NewContext(req)
if err != nil {
log.Errorf("HTTP failed to create context: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer ctx.Close()
// Run the handler, grab the error, and report it
buf := new(httpbuf.Buffer)
log.Tracef("HTTP[%v] %v %v %q", req.RemoteAddr, req.Proto, req.Method, req.RequestURI)
err = h(buf, req, ctx)
if err != nil {
log.Errorf("HTTP error handling %q: %v", req.RequestURI, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Save the session
if err = ctx.Session.Save(req, buf); err != nil {
log.Errorf("HTTP failed to save session: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Apply the buffered response to the writer
if _, err = buf.Apply(w); err != nil {
log.Errorf("HTTP failed to write response: %v", err)
}
}

83
httpd/template.go Normal file
View File

@@ -0,0 +1,83 @@
package httpd
import (
"html/template"
"net/http"
"path"
"path/filepath"
"strings"
"sync"
"github.com/jhillyerd/inbucket/log"
)
var cachedMutex sync.Mutex
var cachedTemplates = map[string]*template.Template{}
var cachedPartials = map[string]*template.Template{}
// RenderTemplate fetches the named template and renders it to the provided
// ResponseWriter.
func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error {
t, err := ParseTemplate(name, false)
if err != nil {
log.Errorf("Error in template '%v': %v", name, err)
return err
}
w.Header().Set("Expires", "-1")
return t.Execute(w, data)
}
// RenderPartial fetches the named template and renders it to the provided
// ResponseWriter.
func RenderPartial(name string, w http.ResponseWriter, data interface{}) error {
t, err := ParseTemplate(name, true)
if err != nil {
log.Errorf("Error in template '%v': %v", name, err)
return err
}
w.Header().Set("Expires", "-1")
return t.Execute(w, data)
}
// ParseTemplate loads the requested template along with _base.html, caching
// the result (if configured to do so)
func ParseTemplate(name string, partial bool) (*template.Template, error) {
cachedMutex.Lock()
defer cachedMutex.Unlock()
if t, ok := cachedTemplates[name]; ok {
return t, nil
}
tempPath := strings.Replace(name, "/", string(filepath.Separator), -1)
tempFile := filepath.Join(webConfig.TemplateDir, tempPath)
log.Tracef("Parsing template %v", tempFile)
var err error
var t *template.Template
if partial {
// Need to get basename of file to make it root template w/ funcs
base := path.Base(name)
t = template.New(base).Funcs(TemplateFuncs)
t, err = t.ParseFiles(tempFile)
} else {
t = template.New("_base.html").Funcs(TemplateFuncs)
t, err = t.ParseFiles(filepath.Join(webConfig.TemplateDir, "_base.html"), tempFile)
}
if err != nil {
return nil, err
}
// Allows us to disable caching for theme development
if webConfig.TemplateCache {
if partial {
log.Tracef("Caching partial %v", name)
cachedTemplates[name] = t
} else {
log.Tracef("Caching template %v", name)
cachedTemplates[name] = t
}
}
return t, nil
}