mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-17 17:47:03 +00:00
Reorganize packages, closes #79
- All packages go into either cmd or pkg directories - Most packages renamed - Server packages moved into pkg/server - sanitize moved into webui, as that's the only place it's used - filestore moved into pkg/storage/file - Makefile updated, and PKG variable use fixed
This commit is contained in:
68
pkg/server/web/context.go
Normal file
68
pkg/server/web/context.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// Context is passed into every request handler function
|
||||
type Context struct {
|
||||
Vars map[string]string
|
||||
Session *sessions.Session
|
||||
DataStore datastore.DataStore
|
||||
MsgHub *msghub.Hub
|
||||
WebConfig config.WebConfig
|
||||
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")
|
||||
if err != nil {
|
||||
if sess == nil {
|
||||
// No session, must fail
|
||||
return nil, err
|
||||
}
|
||||
// The session cookie was probably signed by an old key, ignore it
|
||||
// gorilla created an empty session for us
|
||||
err = nil
|
||||
}
|
||||
ctx := &Context{
|
||||
Vars: vars,
|
||||
Session: sess,
|
||||
DataStore: DataStore,
|
||||
MsgHub: msgHub,
|
||||
WebConfig: webConfig,
|
||||
IsJSON: headerMatch(req, "Accept", "application/json"),
|
||||
}
|
||||
return ctx, err
|
||||
}
|
||||
64
pkg/server/web/helpers.go
Normal file
64
pkg/server/web/helpers.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/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, "&", "&", -1)
|
||||
return fmt.Sprintf("<a href=\"%s\" target=\"_blank\">%s</a>", unescaped, url)
|
||||
}
|
||||
30
pkg/server/web/helpers_test.go
Normal file
30
pkg/server/web/helpers_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package web
|
||||
|
||||
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("<html>"))
|
||||
|
||||
// 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&n=v</a>"))
|
||||
}
|
||||
15
pkg/server/web/rest.go
Normal file
15
pkg/server/web/rest.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package web
|
||||
|
||||
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)
|
||||
}
|
||||
159
pkg/server/web/server.go
Normal file
159
pkg/server/web/server.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Package web provides the plumbing for Inbucket's web GUI and RESTful API
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
"github.com/jhillyerd/inbucket/pkg/log"
|
||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||
)
|
||||
|
||||
// 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 datastore.DataStore
|
||||
|
||||
// msgHub holds a reference to the message pub/sub system
|
||||
msgHub *msghub.Hub
|
||||
|
||||
// Router is shared between httpd, webui and rest packages. It sends
|
||||
// incoming requests to the correct handler function
|
||||
Router = mux.NewRouter()
|
||||
|
||||
webConfig config.WebConfig
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
sessionStore sessions.Store
|
||||
globalShutdown chan bool
|
||||
|
||||
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
|
||||
ExpWebSocketConnectsCurrent = new(expvar.Int)
|
||||
)
|
||||
|
||||
func init() {
|
||||
m := expvar.NewMap("http")
|
||||
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
|
||||
}
|
||||
|
||||
// Initialize sets up things for unit tests or the Start() method
|
||||
func Initialize(
|
||||
cfg config.WebConfig,
|
||||
shutdownChan chan bool,
|
||||
ds datastore.DataStore,
|
||||
mh *msghub.Hub) {
|
||||
|
||||
webConfig = cfg
|
||||
globalShutdown = shutdownChan
|
||||
|
||||
// NewContext() will use this DataStore for the web handlers
|
||||
DataStore = ds
|
||||
msgHub = mh
|
||||
|
||||
// Content Paths
|
||||
log.Infof("HTTP templates mapped to %q", cfg.TemplateDir)
|
||||
log.Infof("HTTP static content mapped to %q", cfg.PublicDir)
|
||||
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
||||
http.FileServer(http.Dir(cfg.PublicDir))))
|
||||
http.Handle("/", Router)
|
||||
|
||||
// Session cookie setup
|
||||
if cfg.CookieAuthKey == "" {
|
||||
log.Infof("HTTP generating random cookie.auth.key")
|
||||
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
|
||||
} else {
|
||||
log.Tracef("HTTP using configured cookie.auth.key")
|
||||
sessionStore = sessions.NewCookieStore([]byte(cfg.CookieAuthKey))
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins listening for HTTP requests
|
||||
func Start(ctx context.Context) {
|
||||
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)
|
||||
emergencyShutdown()
|
||||
return
|
||||
}
|
||||
|
||||
// Listener go routine
|
||||
go serve(ctx)
|
||||
|
||||
// Wait for shutdown
|
||||
select {
|
||||
case _ = <-ctx.Done():
|
||||
log.Tracef("HTTP server shutting down on request")
|
||||
}
|
||||
|
||||
// Closing the listener will cause the serve() go routine to exit
|
||||
if err := listener.Close(); err != nil {
|
||||
log.Errorf("Failed to close HTTP listener: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// serve begins serving HTTP requests
|
||||
func serve(ctx context.Context) {
|
||||
// server.Serve blocks until we close the listener
|
||||
err := server.Serve(listener)
|
||||
|
||||
select {
|
||||
case _ = <-ctx.Done():
|
||||
// Nop
|
||||
default:
|
||||
log.Errorf("HTTP server failed: %v", err)
|
||||
emergencyShutdown()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
log.Tracef("HTTP[%v] %v %v %q", req.RemoteAddr, req.Proto, req.Method, req.RequestURI)
|
||||
err = h(w, req, ctx)
|
||||
if err != nil {
|
||||
log.Errorf("HTTP error handling %q: %v", req.RequestURI, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func emergencyShutdown() {
|
||||
// Shutdown Inbucket
|
||||
select {
|
||||
case _ = <-globalShutdown:
|
||||
default:
|
||||
close(globalShutdown)
|
||||
}
|
||||
}
|
||||
83
pkg/server/web/template.go
Normal file
83
pkg/server/web/template.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/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
|
||||
}
|
||||
Reference in New Issue
Block a user